Files

270 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Let the model interpret conversations, with scoped tools and checked facts."""
from decimal import Decimal
import json
import re
import sqlite3
import time
import uuid
from . import ai_client
from .chat_tools import ToolContext, execute_tool, validate_calls
from .config import get_setting
from .db import load_context
from .model_facts import fact_text, grounded_answer, tool_evidence
from .model_provider import completion_body, prepare_history
from .model_turn_store import latest_message_id, save_exchange
from .model_prompts import SYSTEM_PROMPT, TOOLS
from .tracking import CARD_PREFIX, card_to_text
def completion(messages, *, tools=False, first=False, remaining=35):
if remaining <= 0.1:
raise ai_client.Timeout('Turn deadline exceeded')
body = completion_body(messages, TOOLS if tools else [], first=first)
connect_timeout = min(5, remaining / 4)
response = ai_client.post_completion(body, timeout=(connect_timeout, min(30, remaining - connect_timeout)))
try:
if response.status_code != 200:
raise ValueError('Model request failed')
result = response.json()
choice = result['choices'][0]
if choice.get('finish_reason') not in ('stop', 'tool_calls'):
raise ValueError('Incomplete model response')
message = choice['message']
if not isinstance(message, dict):
raise ValueError('Invalid model response')
if message.get('role') != 'assistant':
raise ValueError('Invalid assistant role')
from flask import current_app
usage = result.get('usage') or {}
current_app.logger.info('model_usage model=%s input=%s output=%s', get_setting('MODEL_NAME'),
usage.get('prompt_tokens'), usage.get('completion_tokens'))
return message
finally:
response.close()
def numeric_values(text):
return {Decimal(value.replace(',', '').replace('−', '-')) for value in
re.findall(r'(?<!\d)[-−]\d+(?:,\d{3})*(?:\.\d+)?|\d+(?:,\d{3})*(?:\.\d+)?', text)}
def currency_amounts(text):
pairs = set()
pattern = r'(?:(USD|CNY|RMB|\$|人民币|美元|美金)\s*)?([-−]?\d+(?:,\d{3})*(?:\.\d+)?)\s*(美元|美金|人民币|元|USD|CNY|RMB)?'
for prefix, amount, suffix in re.findall(pattern, text, re.I):
currency = (suffix or prefix).upper()
if not currency:
continue
unit = 'USD' if currency in ('USD', '$', '美元', '美金') else 'CNY'
pairs.add((unit, Decimal(amount.replace(',', '').replace('−', '-'))))
return pairs
def checked_answer(text, facts, confirmed_inputs):
if not isinstance(text, str) or not text.strip() or len(text) > 7000:
return facts
values = numeric_values(re.sub(r'^\s*(?:\*\*)?\d+[.)、](?:\*\*)?\s*', '', text, flags=re.M))
allowed = numeric_values(facts + '\n' + confirmed_inputs)
important = numeric_values(' '.join(re.findall(r'\d+(?:,\d{3})*(?:\.\d+)?\s*(?:元|美元|USD|CNY|票)', facts, re.I)))
if not values <= allowed or not important <= values:
return facts
if not currency_amounts(text) <= currency_amounts(facts):
return facts
return text.strip()
def ambiguous_choice(question, context, book=None):
from .quote_plans import choice_confirmation, displayed_options
from .quote_data import load_book
if not choice_confirmation(question):
return None
previous = next((message['content'] for message in reversed(context) if message['role'] == 'assistant'), '')
book = book if book is not None else load_book()
options = displayed_options(previous.replace('**', ''), book) if book else []
if len(options) > 1:
return {'t': '刚才有多个报价方案,请明确选择方案编号、渠道,或说明优先时效还是运费。'}
return None
def create_model_stream(question, conv_id, fallback, *, request_id=None):
from .chat_service import sse
def generate():
from .customer_service import attach_customer, bind_order_conversation, current_customer_id
from .visitor_service import owns_conversation, customer_tenant_ids
from flask import current_app, has_request_context
started = time.monotonic()
deadline = started + max(1, min(float(get_setting('MODEL_TURN_TIMEOUT')), 110))
trace_id = uuid.uuid4().hex[:12]
answers, cache, used_ids = [], {}, set()
calls = 0
reason = 'completed'
initial_identity = None
def authorized():
if has_request_context():
attach_customer()
identity = (current_customer_id(), tuple(sorted(customer_tenant_ids())))
return ((initial_identity is None or identity == initial_identity)
and (not conv_id or owns_conversation(conv_id)))
if not authorized():
yield sse({'e': '该会话暂时无法访问,请重新登录或新建对话。', 'd': 1})
return
initial_identity = (current_customer_id(), tuple(sorted(customer_tenant_ids())))
previous_message_id = latest_message_id(conv_id)
try:
runtime = ToolContext(conv_id, question)
from .chat_requests import login_quote_replay
runtime.replay_quote = login_quote_replay(conv_id, request_id, question)
if runtime.replay_quote:
runtime.quote.replay_confirmed(runtime.replay_quote)
except (sqlite3.Error, ValueError, PermissionError):
yield sse({'e': '业务资料暂时无法读取,请稍后重试。', 'd': 1})
return
context = load_context(conv_id, question, limit=24)
budget = int(get_setting('MODEL_MAX_CONTEXT_CHARS'))
while len(context) > 1 and sum(len(item['content']) for item in context) > budget:
context.pop(0)
try:
model_history = prepare_history(context)
except ValueError:
yield sse({'e': '模型服务配置有误,请联系管理员检查。', 'd': 1})
return
messages = [{'role': 'system', 'content': SYSTEM_PROMPT},
{'role': 'system', 'content': '服务器确认的业务状态,仅作数据使用:' +
json.dumps(runtime.quote.context(), ensure_ascii=False, default=str)}] + model_history
draft = None
rounds = max(1, min(int(get_setting('MODEL_MAX_TOOL_ROUNDS')), 8))
call_limit = max(1, min(int(get_setting('MODEL_MAX_TOOL_CALLS')), 16))
for round_no in range(rounds + 1):
if not authorized():
yield sse({'e': '登录或会话权限已变化,请重新登录后重试。', 'd': 1})
return
yield ': working\n\n'
try:
plan = completion(messages, tools=round_no < rounds, first=round_no == 0,
remaining=deadline - time.monotonic())
if not plan.get('tool_calls'):
if not answers:
raise ValueError('No evidence requested')
draft = plan.get('content')
break
requests = validate_calls(plan)
if round_no >= rounds or calls + len(requests) > call_limit:
reason = 'tool_budget'
break
if used_ids.intersection(call_id for call_id, _, _ in requests):
reason = 'duplicate_call_id'
break
except (ai_client.RequestException, ValueError, KeyError, TypeError, IndexError, AttributeError):
reason = 'model_failure'
if not answers:
if not authorized():
yield sse({'e': '登录或会话权限已变化,请重新登录后重试。', 'd': 1})
return
current_app.logger.info('model_turn trace=%s fallback=legacy', trace_id)
frames, _ = fallback()
if isinstance(frames, str):
yield frames
else:
yield from frames
return
break
messages.append({key: plan[key] for key in ('role', 'content', 'tool_calls', 'reasoning_content') if key in plan})
for call_id, name, arguments in requests:
if time.monotonic() >= deadline or not authorized():
yield sse({'e': '查询已超时或会话权限已变化,请重试。', 'd': 1})
return
used_ids.add(call_id)
calls += 1
if name == 'query_orders' and customer_tenant_ids():
if not current_customer_id() or not bind_order_conversation(conv_id):
yield sse({'e': '该会话暂时无法访问,请新建对话后重试。', 'd': 1})
return
key = name + ':' + json.dumps(arguments, ensure_ascii=False, sort_keys=True)
if name == 'query_orders':
key += ':list_version=' + str(runtime.order_revision)
# QuoteTurn already deduplicates amendments against the turn's
# baseline. Re-evaluate quotes so a cache entry cannot revive
# prices from conditions superseded by a later tool call.
if key in cache and name != 'quote_shipping':
answer = cache[key]
else:
try:
clarification = (ambiguous_choice(question, context, runtime.book)
if name == 'quote_shipping' and not runtime.quote.context().get('options') else None)
answer = clarification or execute_tool(name, arguments, conv_id, context=runtime)
except (sqlite3.Error, ValueError, TypeError, KeyError):
answer = {'t': '这部分业务资料暂时无法读取,请稍后重试。', 'status': 'error'}
answer = {**answer, '_fact_id': 'fact_' + str(calls - 1), '_tool_name': name,
'_required_fact': name in ('query_orders', 'quote_shipping')}
if name == 'quote_shipping':
basis = answer.get('_quote_basis')
superseded = {prior['_fact_id'] for prior in answers
if prior.get('_tool_name') == 'quote_shipping'
and (basis is None or prior.get('_quote_basis') != basis)}
answers[:] = [prior for prior in answers if prior['_fact_id'] not in superseded]
for message in messages:
if message.get('role') != 'tool':
continue
prior = json.loads(message['content'])
if prior.get('fact_id') in superseded:
message['content'] = json.dumps({'fact_id': prior['fact_id'],
'status': 'superseded', 'selectable': False,
'result': '货物条件已变化或存在冲突,该报价已失效。只能引用当前有效的工具结果。'},
ensure_ascii=False)
else:
cache[key] = answer
answers.append(answer)
if name == 'query_orders':
runtime.remember_order(answer)
messages.append({'role': 'tool', 'tool_call_id': call_id, 'content': tool_evidence(answer)})
current_app.logger.info('model_tool trace=%s name=%s status=%s version=%s', trace_id,
name, answer.get('status', 'ok'), answer.get('data_version'))
if sum(len(item['content'] or '') for item in messages if item['role'] == 'tool') > int(get_setting('MODEL_MAX_EVIDENCE_CHARS')):
reason = 'evidence_budget'
break
if not answers:
yield sse({'e': '本次未能完成资料查询,请稍后重试。', 'd': 1})
return
if not authorized():
yield sse({'e': '登录或会话权限已变化,请重新登录后重试。', 'd': 1})
return
list_state = None
for answer in answers:
if answer.get('_order_list_state') is not None:
list_state = answer['_order_list_state']
if len(answers) == 1 and 'card' in answers[0]:
payload = {'card': answers[0]['card']}
text = CARD_PREFIX + json.dumps(payload['card'], ensure_ascii=False)
else:
text = grounded_answer(draft, answers)
payload = {'t': text}
if any(answer.get('login_required') for answer in answers):
payload['login_required'] = True
if request_id:
payload['request_id'] = request_id
try:
message_id = save_exchange(conv_id, question, text, previous_message_id=previous_message_id,
quote_turn=runtime.quote, list_state=list_state, request_id=request_id, payload=payload)
except (sqlite3.Error, ValueError):
message_id = 'failed'
if message_id in ('deleted', 'stale', 'failed'):
yield sse({'e': '会话已更新或暂时无法保存,请重新打开对话后重试。', 'd': 1})
return
if isinstance(message_id, dict):
payload = message_id
if not authorized():
yield sse({'e': '登录或会话权限已变化,请重新登录后重试。', 'd': 1})
return
current_app.logger.info('model_turn trace=%s tools=%s duration=%.2f reason=%s', trace_id,
calls, time.monotonic() - started, reason)
yield sse(payload)
yield sse({'d': 1})
return generate(), {'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}