161 lines
7.3 KiB
Python
161 lines
7.3 KiB
Python
"""Let the model interpret conversations, with scoped tools and checked facts."""
|
||
|
||
from decimal import Decimal
|
||
import json
|
||
import re
|
||
import sqlite3
|
||
|
||
from . import ai_client
|
||
from .chat_tools import execute_tool, validate_calls
|
||
from .config import get_setting
|
||
from .db import load_context, save_message
|
||
from .model_prompts import SYSTEM_PROMPT, TOOLS
|
||
from .tracking import CARD_PREFIX, card_to_text
|
||
|
||
|
||
def completion(messages, *, tools=False):
|
||
body = {'model': get_setting('MODEL_NAME'), 'messages': list(messages), 'stream': False,
|
||
'temperature': 0, 'max_tokens': 1800}
|
||
if tools:
|
||
body.update(tools=TOOLS, tool_choice='required')
|
||
response = ai_client.post_completion(body, timeout=(5, 30))
|
||
try:
|
||
if response.status_code != 200:
|
||
raise ValueError('Model request failed')
|
||
choice = response.json()['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')
|
||
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):
|
||
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 = 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):
|
||
from .chat_service import sse
|
||
|
||
def generate():
|
||
context = load_context(conv_id, question, limit=24)
|
||
messages = [{'role': 'system', 'content': SYSTEM_PROMPT}] + context
|
||
try:
|
||
plan = completion(messages, tools=True)
|
||
requests = validate_calls(plan)
|
||
except (ai_client.RequestException, ValueError, KeyError, TypeError, IndexError, AttributeError):
|
||
frames, _ = fallback()
|
||
if isinstance(frames, str):
|
||
yield frames
|
||
else:
|
||
yield from frames
|
||
return
|
||
try:
|
||
clarification = ambiguous_choice(question, context)
|
||
answers = [clarification or execute_tool(name, message, conv_id) for _, name, message in requests]
|
||
except sqlite3.Error:
|
||
yield sse({'e': '业务资料暂时无法读取,请稍后重试。', 'd': 1})
|
||
return
|
||
private = any(answer.get('_customer_data') or 'card' in answer for answer in answers)
|
||
if private:
|
||
from .customer_service import bind_order_conversation
|
||
if not bind_order_conversation(conv_id):
|
||
yield sse({'e': '该会话暂时无法访问,请新建对话后重试。', 'd': 1})
|
||
return
|
||
if save_message(conv_id, 'user', question) == 'deleted':
|
||
yield sse({'e': '该会话已删除,请新建对话后重试。', 'd': 1})
|
||
return
|
||
if len(answers) == 1 and 'card' in answers[0]:
|
||
card = answers[0]['card']
|
||
if save_message(conv_id, 'assistant', CARD_PREFIX + json.dumps(card, ensure_ascii=False)) == 'deleted':
|
||
yield sse({'e': '该会话已删除,请新建对话后重试。', 'd': 1})
|
||
return
|
||
yield sse({'card': card})
|
||
yield sse({'d': 1})
|
||
return
|
||
messages.append({key: plan[key] for key in ('role', 'content', 'tool_calls', 'reasoning_content') if key in plan})
|
||
facts = []
|
||
confirmed_inputs = []
|
||
list_state = None
|
||
login_required = False
|
||
for (call_id, _, _), answer in zip(requests, answers):
|
||
text = (card_to_text(CARD_PREFIX + json.dumps(answer['card'], ensure_ascii=False))
|
||
if 'card' in answer else answer['t'])
|
||
facts.append(text)
|
||
tool_content = text
|
||
if answer.get('_confirmed_quote'):
|
||
confirmed = json.dumps(answer['_confirmed_quote'], ensure_ascii=False, default=str)
|
||
confirmed_inputs.append(confirmed)
|
||
tool_content = json.dumps({'result': text, 'confirmed_inputs': answer['_confirmed_quote']}, ensure_ascii=False, default=str)
|
||
messages.append({'role': 'tool', 'tool_call_id': call_id, 'content': tool_content})
|
||
if answer.get('_order_list_state') is not None:
|
||
list_state = answer['_order_list_state']
|
||
login_required = login_required or answer.get('login_required', False)
|
||
evidence = '\n\n'.join(facts)
|
||
try:
|
||
reply = completion(messages)
|
||
text = checked_answer(reply.get('content'), evidence, '\n'.join(confirmed_inputs))
|
||
except (ai_client.RequestException, ValueError, KeyError, TypeError, IndexError, AttributeError):
|
||
text = evidence
|
||
if list_state is not None:
|
||
identifiers = [row['inbound_code'] or row['order_no'] for row in list_state['shown']]
|
||
positions = [text.find(identifier) for identifier in identifiers]
|
||
if any(position < 0 for position in positions) or positions != sorted(positions):
|
||
text = evidence
|
||
message_id = save_message(conv_id, 'assistant', text)
|
||
if message_id == 'deleted':
|
||
yield sse({'e': '该会话已删除,请新建对话后重试。', 'd': 1})
|
||
return
|
||
if list_state is not None:
|
||
from .order_listing import save_state
|
||
save_state(conv_id, list_state, message_id)
|
||
payload = {'t': text}
|
||
if login_required:
|
||
payload['login_required'] = True
|
||
yield sse(payload)
|
||
yield sse({'d': 1})
|
||
return generate(), {'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}
|