mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 19:59:26 +00:00
fix(learning-harness): a hunt that ends in prose is asked to format it
Measured live against DeepSeek on one pair: the model spends its step budget on tools and then answers in a paragraph that names the sink, the argument and the line -- which parses as nothing. A detector that found the bug and reported zero is indistinguishable from one that found nothing, and every family in round zero would have baselined at zero for this reason alone. The loop now takes one more turn when the hunt ends in prose or runs out of steps, asking the provider for JSON and handing back the reasoning the model already produced. It formats an answer that was reached; it never asks for a new one, and a hunt that called no tool never gets there. Same pair, same model, before and after: recall 0.00 -> 1.00, Youden 0.00 -> 1.00 over three runs per side. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
This commit is contained in:
co-authored by
Claude Opus 5
parent
a1e7b1a20f
commit
d2ebb58007
@@ -168,6 +168,7 @@ class ChatClient(Protocol):
|
||||
messages: list[dict[str, object]],
|
||||
tools: list[dict[str, object]],
|
||||
timeout_seconds: int = 60,
|
||||
json_object: bool = False, # the final answer turn asks for JSON; adapters without it ignore the flag
|
||||
) -> ChatResponse:
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -216,7 +217,9 @@ class OpenRouterHunterClient:
|
||||
messages: list[dict[str, object]],
|
||||
tools: list[dict[str, object]],
|
||||
timeout_seconds: int = 60,
|
||||
json_object: bool = False,
|
||||
) -> ChatResponse:
|
||||
del json_object # OpenRouter routes to many providers; the prompt asks for JSON and the parser tolerates prose
|
||||
message = self._client.complete_chat(model=model, messages=messages, tools=tools, timeout_seconds=timeout_seconds)
|
||||
return chat_response_from_message(message)
|
||||
|
||||
@@ -343,8 +346,54 @@ def run_tool_hunter(
|
||||
if not content.strip() or not used_tools:
|
||||
# A findings dump is not evidence unless the loop invoked a repo tool.
|
||||
return []
|
||||
return _findings_from_content(root, hotspots, content, tags)
|
||||
return []
|
||||
findings = _findings_from_content(root, hotspots, content, tags)
|
||||
if findings or _looks_like_json(content):
|
||||
return findings
|
||||
return _final_answer(client, model, messages, schemas, content, root, hotspots, tags)
|
||||
return _final_answer(client, model, messages, schemas, "", root, hotspots, tags) if used_tools else []
|
||||
|
||||
|
||||
def _final_answer(
|
||||
client: ChatClient,
|
||||
model: str,
|
||||
messages: list[dict[str, object]],
|
||||
schemas: list[dict[str, object]],
|
||||
prose: str,
|
||||
root: Path,
|
||||
hotspots: Sequence[Hotspot],
|
||||
tags: Sequence[str],
|
||||
) -> list[StaticFinding]:
|
||||
"""One more turn, asking the provider for JSON, when the hunt ended in prose or ran out of steps.
|
||||
|
||||
Measured live against DeepSeek: the model spends its step budget on tools and then answers in a paragraph that
|
||||
names the sink, the argument and the line — which parses as nothing at all. A detector that found the bug and
|
||||
reported zero is indistinguishable from one that found nothing, and every family would baseline at zero for
|
||||
that reason alone. This turn formats an answer the model has already reached; it never asks for a new one, and
|
||||
a hunt that called no tool never gets here.
|
||||
"""
|
||||
body = prose.strip()
|
||||
tail: list[dict[str, object]] = [{"role": "assistant", "content": body}] if body else []
|
||||
messages = [
|
||||
*messages,
|
||||
*tail,
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"Answer now, as JSON only: an array of objects with path, line, title, rationale and family. "
|
||||
"Report only what you established with the tools; return [] if that is nothing."
|
||||
),
|
||||
},
|
||||
]
|
||||
response = client.complete(model=model, messages=messages, tools=schemas, json_object=True)
|
||||
return _findings_from_content(root, hotspots, response.content or "", tags)
|
||||
|
||||
|
||||
def _looks_like_json(content: str) -> bool:
|
||||
stripped = content.strip()
|
||||
fenced = re.fullmatch(r"```(?:json)?\s*(.*?)\s*```", stripped, flags=re.DOTALL)
|
||||
if fenced is not None:
|
||||
stripped = fenced.group(1).strip()
|
||||
return stripped.startswith(("[", "{"))
|
||||
|
||||
|
||||
def _tool_schemas(names: Sequence[str] | None) -> list[dict[str, object]]:
|
||||
|
||||
Reference in New Issue
Block a user