Files
baiyue-logistics-chat/logistics_chat/model_chat_service.py
T

161 lines
7.3 KiB
Python
Raw 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
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'}