Skip to content

Commit e45150e

Browse files
improve claude response handling, prepare for fable-5 (#2595)
* fix: harden narrative report generation against max_tokens truncation claude-sonnet-5 has adaptive thinking on by default, and max_tokens is a hard cap on thinking + response text combined. Existing max_tokens values (3000-4000) were sized for text-only output on older models, causing a minority of narrative/collective-statement responses to truncate mid-JSON. - Raise max_tokens to 8000 across all Anthropic narrative-generation call sites (delphi model_provider.py, 801_narrative_report_batch.py, server collectiveStatement.ts, reportNarrative.ts, sonnet.ts) - Add output_config: {effort: "medium"} to bound thinking depth - Log a warning whenever stop_reason == "max_tokens" so truncation is visible in logs instead of silently producing bad data - Prompt hardening: instruct the model to prioritize completing the JSON structure over exhaustive detail if space is limited - Client-side defense in depth: CommentsReport.jsx no longer requires report_data to end with "}" before attempting jsonrepair, which can already recover a valid partial object from truncated responses - Bump @anthropic-ai/sdk in server/node_modules to the 0.110.0 already pinned in package.json (needed for output_config typings) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * chore: remove dead per-job model/stage config for Delphi FULL_PIPELINE and batch jobs jobs.ts built a job_config.stages structure (PCA/UMAP/REPORT configs, model, include_topics, visualizations) that job_poller.py's FULL_PIPELINE branch and run_delphi.py never read — confirmed by tracing the full invocation path. The Anthropic model used for narrative generation is controlled exclusively by the ANTHROPIC_MODEL environment variable (job_poller.py reads it directly for both FULL_PIPELINE and CREATE_NARRATIVE_BATCH jobs, ignoring any per-request model). Removed the same dead fields from the client since the server never used them: jobFormData's max_votes/batch_size/model/include_topics, and the model field on the batchReports request (which was similarly recorded in job_config but ignored by job_poller.py at generation time). This keeps a single source of truth for model selection (the ANTHROPIC_MODEL secret) instead of a misleading per-request parameter that looked configurable but never was. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat(delphi): add claude-fable-5 support with refusal/fallback handling Claude Fable 5 runs safety classifiers that can decline a request (stop_reason: "refusal", HTTP 200 - not an error), which none of our narrative-generation code accounted for. Without handling, a refusal would silently produce an empty/garbage report. - Add "claude-fable-5" to model_provider.py's hardcoded model list - New helpers in model_provider.py: _anthropic_request_extras() (adds the server-side fallbacks param + required beta header, but only when the active model is claude-fable-5 - a no-op for every other model), _narrative_error_json(), and _check_response_for_issues() - get_response()/get_completion() (the two non-batch HTTP call paths) now opt into fallback to claude-opus-4-8 on Fable 5, and turn a refusal into a clear "declined by safety classifier" report instead of empty content - get_batch_responses() (unused) explicitly does not add fallbacks, since the Batch API rejects that param - 803_check_batch_status.py (the live batch-results parser) detects stop_reason == "refusal" on batch results - which report result.type == "succeeded" per Anthropic's docs, so this needed an explicit check - and stores the same clear error message instead of silently writing "{}" Server-side fallback isn't available on the Batch API, so a refused batch item can't be auto-recovered in place; it now at least fails visibly instead of looking like a successful empty report. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 38c1ac4 commit e45150e

8 files changed

Lines changed: 190 additions & 65 deletions

File tree

client-report/src/components/commentsReport/CommentsReport.jsx

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,6 @@ const CommentsReport = ({ math, comments, conversation, ptptCount, formatTid, vo
3434
const jobFormData = {
3535
job_type: "FULL_PIPELINE",
3636
priority: 50,
37-
max_votes: "",
38-
batch_size: "",
39-
model: "claude-opus-4-8",
40-
include_topics: true,
4137
include_moderation: reportModLevel !== -2,
4238
};
4339
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -283,7 +279,6 @@ const CommentsReport = ({ math, comments, conversation, ptptCount, formatTid, vo
283279
net
284280
.polisPost("/api/v3/delphi/batchReports", {
285281
report_id: report_id,
286-
model: "claude-opus-4-8",
287282
no_cache: false,
288283
include_moderation: reportModLevel !== -2,
289284
}, authToken)
@@ -781,10 +776,13 @@ const CommentsReport = ({ math, comments, conversation, ptptCount, formatTid, vo
781776
<p>Not enough data has been provided for analysis, please check back later</p>
782777
);
783778

779+
// Only bail out early on data that isn't JSON-ish at all. Don't require a
780+
// trailing "}" here — responses truncated by max_tokens (e.g. from adaptive
781+
// thinking eating into the token budget) are missing their closing brackets,
782+
// and jsonrepair below can usually recover a valid partial object from those.
784783
if (
785784
typeof report.report_data !== "string" ||
786-
!report.report_data.trim().startsWith("{") ||
787-
!report.report_data.trim().endsWith("}")
785+
!report.report_data.trim().startsWith("{")
788786
) {
789787
return (
790788
<article style={{ maxWidth: "600px" }}>

delphi/umap_narrative/801_narrative_report_batch.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1087,15 +1087,21 @@ async def prepare_batch_requests(self):
10871087
- Do not provide explanations, only the JSON
10881088
- Use the exact structure shown above with "id", "title", "paragraphs", etc.
10891089
- Include relevant citations to comment IDs in the data
1090+
- Keep prose concise (2-4 paragraphs). The full JSON object, including
1091+
closing brackets, must fit within the available output length —
1092+
prioritize finishing the JSON structure over exhaustive detail
10901093
"""
10911094

10921095
# Add to batch requests
1096+
# max_tokens is a hard cap on thinking + response text combined
1097+
# (adaptive thinking is on by default on Sonnet 5 / Opus 4.8+),
1098+
# so this needs real headroom beyond the visible JSON text length.
10931099
batch_request = {
10941100
"system": system_lore,
10951101
"messages": [
10961102
{"role": "user", "content": model_prompt}
10971103
],
1098-
"max_tokens": 4000,
1104+
"max_tokens": 8000,
10991105
"metadata": {
11001106
"topic_name": topic_name,
11011107
"topic_key": topic_key,
@@ -1310,7 +1316,8 @@ async def submit_batch(self):
13101316
"custom_id": safe_custom_id,
13111317
"params": {
13121318
"model": self.model,
1313-
"max_tokens": request.get('max_tokens', 4000),
1319+
"max_tokens": request.get('max_tokens', 8000),
1320+
"output_config": {"effort": "medium"},
13141321
"system": system_content,
13151322
"messages": [user_message]
13161323
}

delphi/umap_narrative/803_check_batch_status.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
from datetime import datetime, timedelta, timezone
1616
from botocore.exceptions import ClientError
1717

18+
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
19+
from umap_narrative.llm_factory_constructor.model_provider import _narrative_error_json
20+
1821
# Configure logging
1922
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
2023
logger = logging.getLogger(__name__)
@@ -135,8 +138,31 @@ async def process_batch_results(self, job_item: Dict) -> bool:
135138
custom_id = entry.custom_id
136139
response_message = entry.result.message
137140
model = response_message.model
138-
text_block = next((b for b in response_message.content if b.type == "text"), None)
139-
content = text_block.text if text_block else "{}"
141+
if response_message.stop_reason == "max_tokens":
142+
logger.warning(
143+
f"Job {job_id}: response for {custom_id} was truncated by max_tokens; "
144+
"output may be incomplete/invalid JSON."
145+
)
146+
147+
if response_message.stop_reason == "refusal":
148+
# Batch API refusals still report result.type == "succeeded" — stop_details
149+
# may be null on batch results, so branch on stop_reason alone.
150+
# Server-side fallback isn't available on the Batch API, so this can't be
151+
# auto-recovered here; it needs a separate non-batch retry (which does
152+
# support fallback for claude-fable-5).
153+
logger.warning(
154+
f"Job {job_id}: model {model} declined request for {custom_id} "
155+
"(stop_reason=refusal)."
156+
)
157+
content = _narrative_error_json(
158+
"Model Declined Request",
159+
"This section could not be generated because the request was declined "
160+
"by the model's safety classifier. Try regenerating, or switch to a "
161+
"different model."
162+
)
163+
else:
164+
text_block = next((b for b in response_message.content if b.type == "text"), None)
165+
content = text_block.text if text_block else "{}"
140166

141167
# Reconstruct the section name from the custom_id
142168
parts = custom_id.split('_', 1)

delphi/umap_narrative/llm_factory_constructor/model_provider.py

Lines changed: 112 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,86 @@
1616
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
1717
logger = logging.getLogger(__name__)
1818

19+
# Claude Fable 5 runs safety classifiers that can decline a request
20+
# (stop_reason: "refusal", HTTP 200 - not an error). When it's the active
21+
# model, we opt into server-side fallback so a refusal is transparently
22+
# retried on another model within the same call, rather than silently
23+
# producing an empty/missing report. Not supported on the Batch API, so
24+
# this only applies to the direct (non-batch) HTTP calls below.
25+
# See: https://platform.claude.com/docs/en/build-with-claude/refusals-and-fallback
26+
FABLE_FALLBACK_MODEL = "claude-opus-4-8"
27+
FABLE_FALLBACK_BETA_HEADER = "server-side-fallback-2026-06-01"
28+
29+
30+
def _anthropic_request_extras(model_name: Optional[str]) -> Dict[str, Any]:
31+
"""
32+
Build the extra headers/body fields needed for a direct (non-batch)
33+
Anthropic request, given the active model.
34+
35+
Returns a dict with "headers" and "body" sub-dicts to merge into the
36+
request.
37+
"""
38+
extras: Dict[str, Any] = {"headers": {}, "body": {}}
39+
if model_name == "claude-fable-5":
40+
extras["headers"]["anthropic-beta"] = FABLE_FALLBACK_BETA_HEADER
41+
extras["body"]["fallbacks"] = [{"model": FABLE_FALLBACK_MODEL}]
42+
return extras
43+
44+
45+
def _narrative_error_json(title: str, text: str) -> str:
46+
"""Build a report_data JSON blob matching the schema the client UI expects."""
47+
return json.dumps({
48+
"id": "polis_narrative_error_message",
49+
"title": title,
50+
"paragraphs": [
51+
{
52+
"id": "polis_narrative_error_message",
53+
"title": title,
54+
"sentences": [
55+
{
56+
"clauses": [
57+
{
58+
"text": text,
59+
"citations": []
60+
}
61+
]
62+
}
63+
]
64+
}
65+
]
66+
})
67+
68+
69+
def _check_response_for_issues(response_json: dict, model_name: Optional[str]) -> Optional[str]:
70+
"""
71+
Inspect a completed (non-refused-via-fallback) Anthropic response for
72+
known problem stop reasons. Logs a warning either way, and returns a
73+
user-facing error message string if the response should not be treated
74+
as usable content (e.g. every model in the fallback chain refused).
75+
"""
76+
stop_reason = response_json.get("stop_reason")
77+
if stop_reason == "max_tokens":
78+
logger.warning(
79+
f"Anthropic response for model {model_name} was truncated by max_tokens; "
80+
"output may be incomplete/invalid JSON."
81+
)
82+
elif stop_reason == "refusal":
83+
stop_details = response_json.get("stop_details") or {}
84+
category = stop_details.get("category")
85+
explanation = stop_details.get("explanation")
86+
logger.warning(
87+
f"Anthropic model {model_name} declined the request (stop_reason=refusal, "
88+
f"category={category}): {explanation}"
89+
)
90+
return (
91+
"This section could not be generated because the request was declined "
92+
"by the model's safety classifier"
93+
+ (f" (category: {category})" if category else "")
94+
+ ". Try regenerating, or switch to a different model."
95+
)
96+
return None
97+
98+
1999
class ModelProvider:
20100
"""Base class for model providers."""
21101

@@ -234,21 +314,32 @@ def get_response(self, system_message: str, user_message: str) -> str:
234314
}
235315
logger.info(f"Using Anthropic model '{self.model_name}' via direct HTTP request")
236316
logger.info(f"API key starts with: {self.api_key[:8]}...")
317+
extras = _anthropic_request_extras(self.model_name)
318+
headers.update(extras["headers"])
237319
data = {
238320
"model": self.model_name,
239321
"system": system_message,
240322
"messages": [
241323
{"role": "user", "content": user_message}
242324
],
243-
"max_tokens": 4000
325+
# max_tokens is a hard cap on thinking + response text combined
326+
# (adaptive thinking is on by default on Sonnet 5 / Opus 4.8+),
327+
# so this needs real headroom beyond the visible text length.
328+
"max_tokens": 8000,
329+
"output_config": {"effort": "medium"},
330+
**extras["body"]
244331
}
245332
response = requests.post(
246333
"https://api.anthropic.com/v1/messages",
247334
headers=headers,
248335
json=data
249336
)
250337
response.raise_for_status()
251-
content = response.json()["content"]
338+
response_json = response.json()
339+
error_message = _check_response_for_issues(response_json, self.model_name)
340+
if error_message:
341+
return _narrative_error_json("Model Declined Request", error_message)
342+
content = response_json["content"]
252343
text_blocks = [b for b in content if b.get("type") == "text"]
253344
result = text_blocks[0]["text"] if text_blocks else ""
254345

@@ -307,13 +398,19 @@ def get_batch_responses(self, batch_requests: List[Dict[str, Any]]) -> Dict[str,
307398
}
308399

309400
# Format requests for Batch API
401+
# Note: the server-side "fallbacks" param (used for claude-fable-5
402+
# refusal recovery elsewhere in this file) is rejected by the Batch
403+
# API and must not be added here — a refused batch item just comes
404+
# back with stop_reason "refusal" and needs to be resubmitted
405+
# separately via the non-batch path.
310406
formatted_requests = []
311407
for i, request in enumerate(batch_requests):
312408
req = {
313409
"model": self.model_name,
314410
"system": request.get("system", ""),
315411
"messages": request.get("messages", []),
316-
"max_tokens": request.get("max_tokens", 4000)
412+
"max_tokens": request.get("max_tokens", 8000),
413+
"output_config": {"effort": "medium"}
317414
}
318415

319416
# Add request ID (for correlation on response)
@@ -376,14 +473,15 @@ def list_available_models(self) -> List[str]:
376473
"""
377474
# Anthropic doesn't have a list models endpoint, so we hardcode the known models
378475
available_models = [
476+
"claude-fable-5",
379477
"claude-sonnet-5",
380478
"claude-opus-4-8",
381479
"claude-sonnet-4-6",
382480
]
383481
logger.info(f"Available Anthropic models: {available_models}")
384482
return available_models
385483

386-
async def get_completion(self, system: str, prompt: str, max_tokens: int = 4000) -> Dict[str, Any]:
484+
async def get_completion(self, system: str, prompt: str, max_tokens: int = 8000) -> Dict[str, Any]:
387485
"""
388486
Get a completion from the Anthropic API with the new completion format.
389487
This method is specifically for the batch report generator.
@@ -429,13 +527,19 @@ async def get_completion(self, system: str, prompt: str, max_tokens: int = 4000)
429527
"content-type": "application/json"
430528
}
431529

530+
extras = _anthropic_request_extras(self.model_name)
531+
headers.update(extras["headers"])
432532
data = {
433533
"model": self.model_name,
434534
"system": system,
435535
"messages": [
436536
{"role": "user", "content": prompt}
437537
],
438-
"max_tokens": max_tokens
538+
# max_tokens is a hard cap on thinking + response text combined
539+
# (adaptive thinking is on by default on Sonnet 5 / Opus 4.8+).
540+
"max_tokens": max_tokens,
541+
"output_config": {"effort": "medium"},
542+
**extras["body"]
439543
}
440544

441545
response = requests.post(
@@ -449,6 +553,9 @@ async def get_completion(self, system: str, prompt: str, max_tokens: int = 4000)
449553

450554
# Parse response — filter by type to skip thinking blocks (Sonnet 5+)
451555
response_data = response.json()
556+
error_message = _check_response_for_issues(response_data, self.model_name)
557+
if error_message:
558+
return {"content": _narrative_error_json("Model Declined Request", error_message)}
452559
content = response_data["content"]
453560
text_blocks = [b for b in content if b.get("type") == "text"]
454561
result = text_blocks[0]["text"] if text_blocks else ""

server/src/prompts/report_experimental/scripts/sonnet.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,10 @@ async function main() {
136136
logger.debug(prompt_xml);
137137
const msg = await anthropic.messages.create({
138138
model: "claude-sonnet-5",
139-
max_tokens: 1000,
139+
// max_tokens is a hard cap on thinking + response text combined
140+
// (adaptive thinking is on by default on Sonnet 5).
141+
max_tokens: 8000,
142+
output_config: { effort: "medium" },
140143
system: system_lore,
141144
messages: [
142145
{

server/src/routes/collectiveStatement.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,12 +140,16 @@ ${JSON.stringify(formattedComments, null, 2)}
140140
</condensedJSONSchema>
141141
</responseFormat>
142142
143-
You MUST respond with valid JSON that follows the exact schema above. Each clause must have at least one citation.`;
143+
You MUST respond with valid JSON that follows the exact schema above. Each clause must have at least one citation. The full JSON object, including closing brackets, must fit within the available output length — prioritize finishing the JSON structure over exhaustive detail.`;
144144

145145
try {
146146
const response = await anthropic.messages.create({
147147
model: "claude-opus-4-8",
148-
max_tokens: 3000,
148+
// max_tokens is a hard cap on thinking + response text combined
149+
// (adaptive thinking is on by default on Opus 4.8+), so this needs
150+
// real headroom beyond the visible JSON text length.
151+
max_tokens: 8000,
152+
output_config: { effort: "medium" },
149153
system: systemPrompt,
150154
messages: [
151155
{
@@ -155,6 +159,12 @@ You MUST respond with valid JSON that follows the exact schema above. Each claus
155159
],
156160
});
157161

162+
if (response.stop_reason === "max_tokens") {
163+
logger.warn(
164+
"Anthropic collective statement response was truncated by max_tokens; output may be incomplete/invalid JSON."
165+
);
166+
}
167+
158168
// Parse the JSON response — find text block (adaptive thinking may add thinking blocks)
159169
const textBlock = response.content.find((b) => b.type === "text");
160170
let responseText = textBlock?.type === "text" ? textBlock.text : "";

0 commit comments

Comments
 (0)