Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/WORKBENCH_ASSISTANT.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ python -m quant_agent.workbench_assistant --context reviewed-context.json --outp
问题最多4000字,证据最多30条、每条6000字,总JSON最多60KB;重复ID、未知字段、哈希不匹配均拒绝。

模型仅返回`findings`与`next_steps`。每条finding包含`text`、`citations`、`quotes`。
在线请求显式使用`response_format={"type":"json_object"}`,需要支持该Chat Completions参数的供应商或兼容网关。
JSON模式仍可能只是网关的提示模拟;客户端继续独立校验,格式失败不自动重试。
两类助手都接受完整JSON对象,或整个响应仅包含一个`json`/无语言标记代码围栏的JSON对象。
围栏外的解释文字、多个围栏、额外JSON对象和截断JSON均拒绝;原始响应在去除空白或围栏前检查大小。
引用必须来自输入,逐字片段必须存在于引用来源;无效JSON、超长内容或伪造引用使调用失败,不降级伪装成有效模型结果。
CLI只创建新输出,不覆盖旧建议。失败返回1,日志保留错误类型,不输出可能包含授权头和证据内容的供应商异常全文。
建议仍是待验证判断,引用存在不等于推理正确,也不证明交易策略有效。
Expand Down
16 changes: 11 additions & 5 deletions src/quant_agent/notebook_advisor.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@
import json
import os

from quant_agent.workbench_assistant import advise, validate_answer, validate_context
from quant_agent.workbench_assistant import (
advise,
parse_model_json,
validate_answer,
validate_context,
)


def propose_notebook(context, notebook, *, model=None, invoke=None):
Expand Down Expand Up @@ -51,12 +56,13 @@ def propose_notebook(context, notebook, *, model=None, invoke=None):

raw = (
ChatOpenAI(model=model, temperature=0, timeout=60, max_retries=0)
.invoke([SystemMessage(content=system), HumanMessage(content=serialized)])
.invoke(
[SystemMessage(content=system), HumanMessage(content=serialized)],
response_format={"type": "json_object"},
)
.content
)
if not isinstance(raw, str) or len(raw.encode()) > 120_000:
raise ValueError("Invalid model response")
answer = json.loads(raw)
answer = parse_model_json(raw, max_bytes=120_000)
if set(answer) != {"findings", "next_steps", "patches"}:
raise ValueError("Unexpected proposal fields")
validate_answer({k: answer[k] for k in ("findings", "next_steps")}, context)
Expand Down
25 changes: 21 additions & 4 deletions src/quant_agent/workbench_assistant.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,22 @@ def validate_answer(answer, context):
return answer


def parse_model_json(response, *, max_bytes):
"""Parse one complete object, optionally enclosed in one whole JSON code fence."""
if not isinstance(response, str) or len(response.encode("utf-8")) > max_bytes:
raise ValueError("Invalid or oversized model response")
text = response.strip()
if text.startswith("```"):
fence = re.fullmatch(r"```(?:json)?[ \t]*\r?\n(.*)\r?\n```", text, re.DOTALL)
if fence is None:
raise ValueError("Expected one complete JSON code fence")
text = fence.group(1)
answer = json.loads(text)
if not isinstance(answer, dict):
raise TypeError("Expected model response object")
return answer


def advise(context, *, model=None, invoke=None):
context = validate_context(context)
if not model:
Expand Down Expand Up @@ -113,12 +129,13 @@ def advise(context, *, model=None, invoke=None):

response = (
ChatOpenAI(model=model, temperature=0, timeout=60, max_retries=0)
.invoke([SystemMessage(content=system), HumanMessage(content=request)])
.invoke(
[SystemMessage(content=system), HumanMessage(content=request)],
response_format={"type": "json_object"},
)
.content
)
if not isinstance(response, str) or len(response.encode()) > 60000:
raise ValueError("Invalid or oversized model response")
answer = validate_answer(json.loads(response), context)
answer = validate_answer(parse_model_json(response, max_bytes=60000), context)
return {
**answer,
"mode": "model_assisted",
Expand Down
162 changes: 162 additions & 0 deletions tests/test_model_json_boundary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
"""Public assistant boundaries accept one complete JSON result, never extract prose."""

import hashlib
import json

import pytest

from quant_agent.notebook_advisor import propose_notebook
from quant_agent.workbench_assistant import advise


def request():
text = "Synthetic fixture is missing volume."
return {
"question": "What input is missing?",
"evidence": [
{"id": "E1", "text": text, "sha256": hashlib.sha256(text.encode()).hexdigest()}
],
}


def answer(notebook):
value = {
"findings": [{"text": "Check volume.", "citations": ["E1"], "quotes": ["missing volume"]}],
"next_steps": ["Inspect the input schema."],
}
if notebook:
value["patches"] = []
return value


def call(notebook, **kwargs):
if notebook:
return propose_notebook(request(), {"nbformat": 4, "cells": []}, **kwargs)
return advise(request(), **kwargs)


@pytest.mark.parametrize("notebook", [False, True], ids=["workbench", "notebook"])
@pytest.mark.parametrize("wrapper", ["{}", " \n```json\n{}\n```\t", "```\r\n{}\r\n```"])
def test_accepts_only_complete_json_result(monkeypatch, notebook, wrapper):
monkeypatch.setenv("QUANT_AGENT_LLM_OK", "1")
expected = answer(notebook)
raw = wrapper.format(json.dumps(expected))
report = call(notebook, model="test", invoke=lambda *_: raw)
assert report["findings"] == expected["findings"]
assert report["next_steps"] == expected["next_steps"]
assert report["model_invoked"] is False and report["provider"] == "injected_test"
if notebook:
assert report["patches"] == []


@pytest.mark.parametrize("notebook", [False, True], ids=["workbench", "notebook"])
@pytest.mark.parametrize(
"kind",
[
"prose",
"suffix",
"two_fences",
"two_objects",
"malformed",
"unclosed",
"wrong_language",
"oversized",
"multibyte_oversized",
"non_object",
"non_text",
"unknown_citation",
"invented_quote",
],
)
def test_invalid_result_fails_without_retry(monkeypatch, notebook, kind):
monkeypatch.setenv("QUANT_AGENT_LLM_OK", "1")
value = answer(notebook)
if kind == "unknown_citation":
value["findings"][0]["citations"] = ["E2"]
elif kind == "invented_quote":
value["findings"][0]["quotes"] = ["profitable"]
raw = json.dumps(value)
fenced = "```json\n" + raw + "\n```"
invalid = {
"prose": "Here is the answer:\n" + fenced,
"suffix": fenced + "\nPlease run this.",
"two_fences": fenced + "\n" + fenced,
"two_objects": raw + raw,
"malformed": '```json\n{"findings":\n```',
"unclosed": "```json\n" + raw,
"wrong_language": "```python\n" + raw + "\n```",
# A valid body does not permit bypassing the original raw-byte limit with whitespace.
"oversized": " " * (120_000 if notebook else 60_000) + fenced,
"multibyte_oversized": "中" * (40_000 if notebook else 20_000) + fenced,
"non_object": "```json\n[]\n```",
"non_text": {"text": fenced},
"unknown_citation": fenced,
"invented_quote": fenced,
}[kind]
calls = []

def invoke(*_):
calls.append(True)
return invalid

with pytest.raises(TypeError if kind == "non_object" else ValueError):
call(notebook, model="test", invoke=invoke)
assert calls == [True]


@pytest.mark.parametrize("notebook", [False, True], ids=["workbench", "notebook"])
def test_real_sdk_requests_json_mode_and_keeps_local_validation(monkeypatch, notebook):
import httpx
import langchain_openai

monkeypatch.setenv("QUANT_AGENT_LLM_OK", "1")
monkeypatch.setenv("OPENAI_API_KEY", "synthetic-test-key")
monkeypatch.setenv("OPENAI_BASE_URL", "https://synthetic-provider.invalid/v1")
monkeypatch.setenv("LANGSMITH_TRACING", "false")
monkeypatch.setenv("LANGCHAIN_TRACING_V2", "false")
expected = answer(notebook)
captured, requests = {}, []
actual_client = langchain_openai.ChatOpenAI

def transport(request):
assert request.url.path == "/v1/chat/completions"
requests.append(json.loads(request.content))
return httpx.Response(
200,
json={
"id": "synthetic-completion",
"object": "chat.completion",
"created": 0,
"model": "synthetic-model",
"choices": [
{
"index": 0,
"finish_reason": "stop",
"message": {
"role": "assistant",
"content": "```json\n" + json.dumps(expected) + "\n```",
},
}
],
},
)

with httpx.Client(transport=httpx.MockTransport(transport), trust_env=False) as client:

def factory(**kwargs):
captured.update(kwargs)
return actual_client(**kwargs, http_client=client)

monkeypatch.setattr(langchain_openai, "ChatOpenAI", factory)
report = call(notebook, model="synthetic-model")
assert captured == {
"model": "synthetic-model",
"temperature": 0,
"timeout": 60,
"max_retries": 0,
}
assert len(requests) == 1
assert requests[0]["response_format"] == {"type": "json_object"}
assert "tools" not in requests[0]
assert report["findings"] == expected["findings"]
assert report["model_invoked"] is True and report["provider"] == "configured_provider"
3 changes: 2 additions & 1 deletion tests/test_workbench_assistant.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,10 @@ class FakeClient:
def __init__(self, **kwargs):
captured.update(kwargs)

def invoke(self, messages):
def invoke(self, messages, **kwargs):
assert len(messages) == 2
assert json.loads(messages[1].content) == context()
assert kwargs == {"response_format": {"type": "json_object"}}
return SimpleNamespace(content=json.dumps(answer))

module.ChatOpenAI = FakeClient
Expand Down
Loading