130 lines
5.4 KiB
Python
130 lines
5.4 KiB
Python
"""Pure, stateless Responses wire contract; never sends a request or loads credentials."""
|
|
|
|
import re
|
|
|
|
from app.schemas.rag import ContextRead, ParsedResponse, ResponsesOptions, ResponseUsage
|
|
from app.services.completions import (
|
|
INSTRUCTIONS,
|
|
INSUFFICIENT,
|
|
CompletionProtocolError,
|
|
canonical_json,
|
|
)
|
|
|
|
|
|
def request_body(query: str, evidence: list, options: ResponsesOptions):
|
|
body = {
|
|
"model": options.model,
|
|
"instructions": INSTRUCTIONS,
|
|
"input": [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "input_text",
|
|
"text": canonical_json({"question": query, "evidence": evidence}),
|
|
}
|
|
],
|
|
}
|
|
],
|
|
"max_output_tokens": options.output_tokens,
|
|
"stream": False,
|
|
}
|
|
# DeepSeek is stateless and documents store as unsupported. Other profiles
|
|
# explicitly disable response storage; no previous_response_id is assumed.
|
|
if options.provider_profile != "deepseek":
|
|
body["store"] = False
|
|
if options.temperature is not None:
|
|
body["temperature"] = options.temperature
|
|
return body
|
|
|
|
|
|
def parse_response(payload: dict, context: ContextRead) -> ParsedResponse:
|
|
"""Parse final text output, never treating reasoning or partial text as an answer."""
|
|
try:
|
|
if (
|
|
not isinstance(context.completion, ResponsesOptions)
|
|
or context.status != "prepared"
|
|
or not context.evidence
|
|
or payload["object"] != "response"
|
|
):
|
|
raise ValueError("unexpected context or protocol")
|
|
response_id, model = payload["id"], payload["model"]
|
|
if not isinstance(response_id, str) or not response_id.strip() or len(response_id) > 256:
|
|
raise ValueError("invalid id")
|
|
if not isinstance(model, str) or not model.strip() or len(model) > 128:
|
|
raise ValueError("invalid model")
|
|
usage = (
|
|
ResponseUsage.model_validate(payload["usage"])
|
|
if payload.get("usage") is not None
|
|
else None
|
|
)
|
|
base = dict(response_id=response_id, model=model, usage=usage)
|
|
output = payload["output"]
|
|
if not isinstance(output, list):
|
|
raise ValueError("invalid output")
|
|
status = payload["status"]
|
|
if status == "failed":
|
|
error = payload["error"]
|
|
if not isinstance(error, dict) or not isinstance(error.get("code"), str):
|
|
raise ValueError("missing error")
|
|
return ParsedResponse(**base, status="failed", answer=None, citations=[])
|
|
if payload.get("error") is not None:
|
|
raise ValueError("conflicting error")
|
|
if status == "incomplete":
|
|
reason = payload["incomplete_details"]["reason"]
|
|
if reason not in {"max_output_tokens", "max_messages", "content_filter", "steered"}:
|
|
raise ValueError("unknown incomplete reason")
|
|
return ParsedResponse(
|
|
**base,
|
|
status="refused" if reason == "content_filter" else "incomplete",
|
|
answer=None,
|
|
citations=[],
|
|
)
|
|
if status != "completed" or payload.get("incomplete_details") is not None:
|
|
raise ValueError("not a completed response")
|
|
messages = []
|
|
for item in output:
|
|
if item["type"] == "reasoning":
|
|
continue
|
|
if item["type"] != "message":
|
|
raise ValueError("unsupported tool or output")
|
|
if item["role"] != "assistant" or item["status"] != "completed":
|
|
raise ValueError("incomplete assistant message")
|
|
messages.append(item)
|
|
if len(messages) != 1:
|
|
raise ValueError("expected one assistant message")
|
|
parts = messages[0]["content"]
|
|
if not isinstance(parts, list) or not parts:
|
|
raise ValueError("empty content")
|
|
texts, refused = [], False
|
|
for part in parts:
|
|
if part["type"] == "refusal":
|
|
if not isinstance(part["refusal"], str) or not part["refusal"].strip():
|
|
raise ValueError("invalid refusal")
|
|
refused = True
|
|
elif part["type"] == "output_text" and isinstance(part["text"], str):
|
|
texts.append(part["text"])
|
|
else:
|
|
raise ValueError("unsupported content")
|
|
if refused:
|
|
return ParsedResponse(**base, status="refused", answer=None, citations=[])
|
|
answer = "".join(texts).strip()
|
|
if not answer or len(answer) > 32000:
|
|
raise ValueError("invalid answer")
|
|
if answer == INSUFFICIENT:
|
|
return ParsedResponse(
|
|
**base, status="insufficient_evidence", answer=answer, citations=[]
|
|
)
|
|
markers = re.findall(r"\[[Ee][^\]\r\n]*\]", answer)
|
|
allowed = {f"[{item.citation_id}]" for item in context.evidence}
|
|
if not markers or any(marker not in allowed for marker in markers):
|
|
raise ValueError("missing or unknown citations")
|
|
return ParsedResponse(
|
|
**base,
|
|
status="answer_candidate",
|
|
answer=answer,
|
|
citations=list(dict.fromkeys(marker[1:-1] for marker in markers)),
|
|
)
|
|
except (KeyError, TypeError, ValueError, AttributeError) as exc:
|
|
raise CompletionProtocolError("模型响应格式或引用无效") from exc
|