File size: 31,784 Bytes
7a3d380 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 | """Phase 2 AI layer: FNOL claim intake and schedule Q&A via the Claude API.
Two capabilities, both consumed by app.py:
1. extract_claims(fnol_text) - turns free-text First Notice of Loss
(emails, call notes) into structured claim records via Claude's
structured outputs, ready to append to claims.csv.
2. ScheduleAssistant - a dispatcher chat assistant with tool access to
the current solved schedule. It can explain assignments ("why was
CLM-007 dropped?"), look up claims/adjusters, and run hypothetical
re-solves ("what if ADJ-01 is out sick?") without touching the
baseline solution.
Authentication: the Anthropic client resolves credentials from the
environment (ANTHROPIC_API_KEY, or an `ant auth login` profile).
"""
from __future__ import annotations
import json
from typing import Literal, Optional
import anthropic
from pydantic import BaseModel
import config
import distance
import solver
from data_gen import min_to_hhmm
MODEL = "claude-opus-4-8"
_client: anthropic.Anthropic | None = None
def client() -> anthropic.Anthropic:
global _client
if _client is None:
_client = anthropic.Anthropic()
return _client
class AssistantError(RuntimeError):
"""User-friendly wrapper for API failures."""
NO_KEY_MSG = ("No Claude API credentials. Set the ANTHROPIC_API_KEY "
"environment variable before starting the app "
"(https://platform.claude.com -> API keys).")
def _friendly(e: Exception) -> AssistantError:
# A key-less client raises TypeError('Could not resolve authentication
# method...') at call time rather than an APIError subclass.
if isinstance(e, TypeError) and "authentication" in str(e).lower():
return AssistantError(NO_KEY_MSG)
if isinstance(e, anthropic.AuthenticationError):
return AssistantError(NO_KEY_MSG)
if isinstance(e, anthropic.APIConnectionError):
return AssistantError("Could not reach the Claude API - check your "
"network connection.")
if isinstance(e, anthropic.RateLimitError):
return AssistantError("Claude API rate limit hit - wait a moment "
"and try again.")
return AssistantError(f"Claude API error: {e}")
# ---------------------------------------------------------------------------
# 1. FNOL claim intake (structured extraction)
# ---------------------------------------------------------------------------
class ExtractedClaim(BaseModel):
policyholder_name: Optional[str]
address: Optional[str]
peril: Literal["fire", "flood", "wind", "hail"]
priority: Literal[1, 2, 3]
window_start: str # "HH:MM"
window_end: str # "HH:MM"
service_minutes: int
lat: Optional[float]
lon: Optional[float]
notes: str
class ExtractionResult(BaseModel):
claims: list[ExtractedClaim]
EXTRACTION_SYSTEM = """\
You extract structured insurance claim records from First Notice of Loss
text (emails, call-center notes) for a field-adjuster routing system.
Rules:
- peril: classify the cause of loss as one of fire, flood, wind, hail.
Water damage from rising water/storm surge is flood; roof/tree damage
from storms is wind.
- priority: 1 = must be inspected TODAY (home uninhabitable, safety risk,
displaced family, or the text demands same-day service); 2 = high
(major damage, distressed policyholder, SLA pressure); 3 = normal.
- window_start / window_end: the policyholder's availability window in
24h HH:MM. If none is stated, use 08:00 and 17:00. "Mornings" means
08:00-12:00; "afternoons" means 12:00-17:00.
- service_minutes: estimated on-site inspection time. Small/localized
damage 60; typical 90; extensive or structural 120; total-loss or
large multi-structure 180.
- lat/lon: ONLY if explicit coordinates appear in the text; never guess
coordinates from an address. Use null otherwise.
- notes: one short sentence summarizing the loss for the adjuster.
- If the text describes multiple properties/claims, return one record
each. If it contains no claim at all, return an empty list."""
def extract_claims(fnol_text: str) -> ExtractionResult:
try:
response = client().messages.parse(
model=MODEL,
max_tokens=4096,
system=EXTRACTION_SYSTEM,
messages=[{"role": "user", "content": fnol_text}],
output_format=ExtractionResult,
)
except (anthropic.APIError, TypeError) as e:
raise _friendly(e) from e
return response.parsed_output
# ---------------------------------------------------------------------------
# 2. Schedule Q&A assistant (tool use)
# ---------------------------------------------------------------------------
TOOLS = [
{
"name": "get_schedule",
"description": (
"Get the current solved schedule: every adjuster's route with "
"stop order, arrival/departure times and drive legs, plus the "
"dropped-claim list and fleet totals. Call this before "
"answering any question about today's plan."),
"input_schema": {"type": "object", "properties": {}},
},
{
"name": "get_claims",
"description": ("List all claims in the current instance with "
"peril, priority, availability window, service "
"time, and location."),
"input_schema": {"type": "object", "properties": {}},
},
{
"name": "get_adjusters",
"description": ("List all adjusters with their skills, shift "
"hours, and home locations."),
"input_schema": {"type": "object", "properties": {}},
},
{
"name": "what_if_solve",
"description": (
"Run a HYPOTHETICAL re-solve of today's schedule and return "
"the resulting plan. Does NOT change the baseline schedule "
"shown in the app. The re-solve uses the SAME solver backend "
"and lunch/balance toggles as the schedule on screen, so its "
"objective is directly comparable to the baseline. In "
"pre-assigned (sequence) mode, upstream assignments stay "
"binding: claims are never moved between adjusters, an "
"excluded adjuster's claims are dropped and reported for "
"rescheduling, and an added adjuster receives no claims. "
"Use for questions like 'what if ADJ-01 is out sick?', "
"'could we serve CLM-007 if it were urgent?', 'would "
"extending ADJ-03 to 19:00 fix the MUST-TODAY violation?', "
"or 'what if we brought in one extra flood-qualified "
"adjuster?'. exclude_adjuster_ids removes adjusters "
"(sick/unavailable); must_today_claim_ids escalates claims "
"to must-inspect-today priority; shift_changes temporarily "
"alters working hours (overtime); add_adjusters brings in "
"hypothetical extra adjusters (new hires / contractors)."),
"input_schema": {
"type": "object",
"properties": {
"exclude_adjuster_ids": {
"type": "array", "items": {"type": "string"},
"description": "Adjuster ids to remove, e.g. ['ADJ-01']",
},
"must_today_claim_ids": {
"type": "array", "items": {"type": "string"},
"description": "Claim ids to escalate to priority 1",
},
"priority_changes": {
"type": "array",
"items": {
"type": "object",
"properties": {
"claim_id": {"type": "string"},
"new_priority": {
"type": "integer", "enum": [1, 2, 3],
"description": "1=MUST-TODAY, 2=high, "
"3=normal"},
},
"required": ["claim_id", "new_priority"],
},
"description": ("Raise OR lower any claim's priority "
"- e.g. de-escalate CLM-012 to "
"normal so it can wait"),
},
"add_adjusters": {
"type": "array",
"items": {
"type": "object",
"properties": {
"adjuster_id": {
"type": "string",
"description": "optional; default TEMP-01,"
" TEMP-02, ..."},
"name": {"type": "string"},
"skills": {
"type": "array",
"items": {"type": "string"},
"description": "perils they can handle: "
"fire, flood, wind, hail"},
"shift_start": {
"type": "string",
"description": "HH:MM, default 08:00"},
"shift_end": {
"type": "string",
"description": "HH:MM, default 17:00"},
"home_lat": {
"type": "number",
"description": "optional; defaults to the "
"region center"},
"home_lon": {"type": "number"},
"max_radius_miles": {
"type": "number",
"description": "optional service "
"territory"},
},
"required": ["skills"],
},
"description": ("Hypothetical extra adjusters, e.g. "
"one flood-qualified contractor "
"working 08:00-18:00"),
},
"shift_changes": {
"type": "array",
"items": {
"type": "object",
"properties": {
"adjuster_id": {"type": "string"},
"new_shift_end": {
"type": "string",
"description": "HH:MM, e.g. '19:00'"},
"new_shift_start": {
"type": "string",
"description": "HH:MM, e.g. '06:00'"},
},
"required": ["adjuster_id"],
},
"description": ("Temporary working-hour changes, "
"e.g. extend ADJ-03's day to 19:00"),
},
"time_limit_s": {
"type": "integer",
"description": ("Solver time limit in seconds. "
"Defaults to the user's main-solve "
"limit capped at 60 for chat "
"responsiveness; explicit values "
"are clamped to the main-solve "
"limit. Pass a smaller value for "
"quick checks."),
},
},
},
},
]
ASSISTANT_SYSTEM = """\
You are the dispatch assistant for an insurance field-adjuster routing
system. You answer questions about today's solved schedule and run
hypothetical what-if re-solves on request.
How the optimizer works (use this to explain its decisions):
- Each adjuster starts and ends at home, works their shift, and visits
claims they are skilled for (peril must match a skill) and that lie
inside their service territory (an optional max radius in road miles
from their home), arriving inside the policyholder's availability
window; on-site service time is fixed.
- The objective minimizes total driving minutes plus penalties for
dropped claims. Penalties: normal=600, high=3000, MUST-TODAY=1,000,000
(in driving-minute units). A claim is dropped when serving it would
cost more than its penalty - because of capacity, windows, skills, or
distance. Dropped claims are rescheduled to a later day.
- A dropped MUST-TODAY claim is a violation requiring human action.
When a MUST-TODAY violation appears, you can actually test the fixes:
what_if_solve accepts shift_changes (temporary overtime, e.g. extend an
adjuster to 19:00), add_adjusters (hypothetical extra adjusters - give
them the skills the violated claim needs; home defaults to the region
center unless told otherwise), priority_changes (raise or LOWER any
claim's priority - de-escalation frees capacity), and exclusions - run
the scenario and report whether it clears the violation and at what
cost. What-if re-solves run the SAME solver backend and lunch/balance
toggles the user picked for the main solve (the scenario block in the
result names them), so objectives are directly comparable. If the
schedule was solved in pre-assigned (sequence) mode, assignments stay
binding in every what-if: claims never move between adjusters, an
excluded adjuster's claims are dropped and reported for rescheduling
(not redistributed), and add_adjusters will not help because a new
adjuster has no assigned claims - say so instead of suggesting it.
Note that
applying a scenario (the user's Apply button) changes today's working
schedule only; permanent hour changes belong in adjusters.csv.
Ground every answer in tool results - call get_schedule before answering
schedule questions rather than answering from memory. Be concise and
concrete: name claims, adjusters, and times. When you run what_if_solve,
compare the hypothetical against the baseline and lead with the impact
(claims served, miles, any MUST-TODAY violations), and remind the user
it has not changed the real schedule."""
class ScheduleAssistant:
"""Multi-turn chat with tool access to the solved schedule."""
def __init__(self):
self.messages: list = []
self.claims = None
self.adjusters = None
self.sol = None
self.last_what_if: dict | None = None # for the apply-scenario flow
# What-if re-solves run through resolver - the same backend +
# toggles as the user's last Solve (None falls back to ortools).
self.resolver = None
self.backend_label = "ortools"
self.toggles: dict = {}
self.default_time_limit = 10
self.mode = "global"
self.matrix_builder = None
self.distance_label = "haversine"
def set_context(self, claims, adjusters, sol, resolver=None,
backend_label="ortools", toggles=None,
default_time_limit=10, mode="global",
matrix_builder=None,
distance_label="haversine") -> None:
self.claims = claims
self.adjusters = adjusters
self.sol = sol
self.resolver = resolver
self.backend_label = backend_label
self.toggles = toggles or {}
self.default_time_limit = int(default_time_limit or 10)
self.mode = mode
self.matrix_builder = matrix_builder
self.distance_label = distance_label
# -- tool implementations ------------------------------------------------
def _schedule_dict(self, sol) -> dict:
return {
"routes": [{
"adjuster": r.adjuster.adjuster_id,
"name": r.adjuster.name,
"leaves_home": min_to_hhmm(r.start_min),
"back_home": min_to_hhmm(r.end_min),
"total_miles": round(r.total_miles, 1),
"stops": [{
"seq": i + 1,
"claim_id": s.claim.claim_id,
"peril": s.claim.peril,
"priority": config.PRIORITY_LABEL[s.claim.priority],
"window": f"{min_to_hhmm(s.claim.window_start)}-"
f"{min_to_hhmm(s.claim.window_end)}",
"on_site": f"{min_to_hhmm(s.arrival_min)}-"
f"{min_to_hhmm(s.departure_min)}",
"drive_miles": round(s.travel_miles_from_prev, 1),
} for i, s in enumerate(r.stops)],
} for r in sol.routes],
"dropped_for_reschedule": [{
"claim_id": c.claim_id,
"peril": c.peril,
"priority": config.PRIORITY_LABEL[c.priority],
"window": f"{min_to_hhmm(c.window_start)}-"
f"{min_to_hhmm(c.window_end)}",
"service_minutes": c.service_minutes,
"no_qualified_adjuster": c in sol.unservable,
} for c in sol.dropped],
"totals": {
"claims_served": sum(len(r.stops) for r in sol.routes),
"claims_total": len(self.claims),
"fleet_miles": round(sol.total_miles, 1),
"driving_minutes": sol.total_travel_min,
"objective": sol.objective,
"must_today_violations": [c.claim_id for c in
sol.dropped_must_today],
},
}
def _get_claims(self) -> list[dict]:
return [{
"claim_id": c.claim_id, "peril": c.peril,
"priority": config.PRIORITY_LABEL[c.priority],
"window": f"{min_to_hhmm(c.window_start)}-"
f"{min_to_hhmm(c.window_end)}",
"service_minutes": c.service_minutes,
"lat": c.lat, "lon": c.lon,
} for c in self.claims]
def _get_adjusters(self) -> list[dict]:
return [{
"adjuster_id": a.adjuster_id, "name": a.name,
"skills": a.skills,
"shift": f"{min_to_hhmm(a.shift_start)}-"
f"{min_to_hhmm(a.shift_end)}",
"home": {"lat": a.home_lat, "lon": a.home_lon},
"territory_radius_miles": a.max_radius_miles,
} for a in self.adjusters]
def _what_if(self, tool_input: dict) -> dict:
exclude = set(tool_input.get("exclude_adjuster_ids") or [])
escalate = set(tool_input.get("must_today_claim_ids") or [])
# Default to the main solve's budget capped at 60s so a chat
# turn stays responsive; explicit requests are clamped to the
# user's own slider setting, never beyond it.
cap = max(1, self.default_time_limit)
requested = tool_input.get("time_limit_s")
time_limit = int(requested) if requested else min(cap, 60)
time_limit = max(1, min(time_limit, cap))
import copy
from data_gen import hhmm_to_min
adjusters = copy.deepcopy([a for a in self.adjusters
if a.adjuster_id not in exclude])
if not adjusters:
return {"error": "cannot exclude every adjuster"}
unknown = exclude - {a.adjuster_id for a in self.adjusters}
if unknown:
return {"error": f"unknown adjuster ids: {sorted(unknown)}"}
shift_changes = tool_input.get("shift_changes") or []
by_id = {a.adjuster_id: a for a in adjusters}
applied_shifts = []
for ch in shift_changes:
a = by_id.get(ch.get("adjuster_id"))
if a is None:
return {"error": f"unknown or excluded adjuster in "
f"shift_changes: {ch.get('adjuster_id')}"}
try:
if ch.get("new_shift_start"):
a.shift_start = hhmm_to_min(ch["new_shift_start"])
if ch.get("new_shift_end"):
a.shift_end = hhmm_to_min(ch["new_shift_end"])
except (ValueError, AttributeError):
return {"error": "shift times must be HH:MM, e.g. '19:00'"}
if a.shift_end <= a.shift_start:
return {"error": f"{a.adjuster_id}: shift end must be "
f"after shift start"}
applied_shifts.append(
{"adjuster_id": a.adjuster_id,
"new_shift_start": ch.get("new_shift_start"),
"new_shift_end": ch.get("new_shift_end")})
from data_gen import Adjuster
added_adjusters = []
for n, spec in enumerate(tool_input.get("add_adjusters") or [],
start=1):
skills = [str(s).strip().lower()
for s in (spec.get("skills") or [])]
if not skills or any(s not in config.PERILS for s in skills):
return {"error": f"add_adjusters skills must be non-empty "
f"and from {config.PERILS}"}
aid = spec.get("adjuster_id") or f"TEMP-{n:02d}"
if any(x.adjuster_id == aid for x in adjusters) \
or aid in {a.adjuster_id for a in self.adjusters}:
return {"error": f"adjuster id {aid} already exists"}
try:
ss = hhmm_to_min(spec.get("shift_start") or "08:00")
se = hhmm_to_min(spec.get("shift_end") or "17:00")
except (ValueError, AttributeError):
return {"error": "shift times must be HH:MM"}
if se <= ss:
return {"error": f"{aid}: shift end must be after start"}
resolved = {
"adjuster_id": aid,
"name": spec.get("name") or f"Temp Adjuster {n}",
"home_lat": float(spec.get("home_lat")
or config.REGION_CENTER[0]),
"home_lon": float(spec.get("home_lon")
or config.REGION_CENTER[1]),
"skills": skills,
"shift_start": ss, "shift_end": se,
"max_radius_miles": (float(spec["max_radius_miles"])
if spec.get("max_radius_miles")
else None),
}
adjusters.append(Adjuster(**resolved))
added_adjusters.append(resolved)
claims = copy.deepcopy(self.claims)
unknown_c = escalate - {c.claim_id for c in claims}
if unknown_c:
return {"error": f"unknown claim ids: {sorted(unknown_c)}"}
for c in claims:
if c.claim_id in escalate:
c.priority = config.PRIORITY_MUST_TODAY
prio_changes = []
by_claim = {c.claim_id: c for c in claims}
for ch in tool_input.get("priority_changes") or []:
c = by_claim.get(ch.get("claim_id"))
if c is None:
return {"error": f"unknown claim id in priority_changes: "
f"{ch.get('claim_id')}"}
p = ch.get("new_priority")
if p not in (1, 2, 3):
return {"error": "new_priority must be 1, 2, or 3"}
c.priority = int(p)
prio_changes.append({"claim_id": c.claim_id,
"new_priority": int(p)})
if self.matrix_builder is not None:
try:
miles, travel_min = self.matrix_builder(adjusters, claims)
except Exception as e:
return {"error": f"distance matrices failed: {e}"}
else:
miles, travel_min = distance.build_matrices(adjusters, claims)
if self.resolver is not None:
sol = self.resolver(adjusters, claims, miles, travel_min,
time_limit)
else:
sol = solver.solve(adjusters, claims, miles, travel_min,
time_limit_s=time_limit)
if sol is None:
return {"error": "no feasible solution found"}
self.last_what_if = {"exclude_adjuster_ids": sorted(exclude),
"must_today_claim_ids": sorted(escalate),
"shift_changes": applied_shifts,
"add_adjusters": added_adjusters,
"priority_changes": prio_changes}
result = self._schedule_dict(sol)
result["scenario"] = {
"solver_backend": self.backend_label,
"toggles": dict(self.toggles),
"mode": self.mode,
"distance_model": self.distance_label,
"time_limit_s": time_limit,
"excluded_adjusters": sorted(exclude),
"escalated_to_must_today": sorted(escalate),
"shift_changes": applied_shifts,
"priority_changes": prio_changes,
"added_adjusters": [
{"adjuster_id": r["adjuster_id"], "skills": r["skills"],
"shift": f"{min_to_hhmm(r['shift_start'])}-"
f"{min_to_hhmm(r['shift_end'])}"}
for r in added_adjusters],
"note": "hypothetical only - baseline schedule unchanged",
}
return result
def _dispatch(self, name: str, tool_input: dict):
if name == "get_schedule":
return self._schedule_dict(self.sol)
if name == "get_claims":
return self._get_claims()
if name == "get_adjusters":
return self._get_adjusters()
if name == "what_if_solve":
return self._what_if(tool_input)
raise ValueError(f"unknown tool: {name}")
# -- the agentic loop ----------------------------------------------------
def ask(self, user_text: str) -> str:
if self.sol is None:
return ("No solved schedule yet - click Solve first, then ask "
"me about the plan.")
checkpoint = len(self.messages)
self.messages.append({"role": "user", "content": user_text})
response = None
try:
for _ in range(8): # tool-round guard
response = client().messages.create(
model=MODEL,
max_tokens=16000,
thinking={"type": "adaptive"},
system=ASSISTANT_SYSTEM,
tools=TOOLS,
messages=self.messages,
)
self.messages.append({"role": "assistant",
"content": response.content})
if response.stop_reason != "tool_use":
break
results = []
for block in response.content:
if block.type != "tool_use":
continue
try:
out = self._dispatch(block.name, dict(block.input))
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": json.dumps(out),
})
except Exception as e:
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": f"Tool error: {e}",
"is_error": True,
})
self.messages.append({"role": "user", "content": results})
except (anthropic.APIError, TypeError) as e:
del self.messages[checkpoint:] # roll back the failed turn
raise _friendly(e) from e
if response is None:
return "Something went wrong - no response from the model."
text = "\n".join(b.text for b in response.content
if b.type == "text")
return text or "(no text response)"
def ask_stream(self, user_text: str):
"""Streaming version of ask(): yields the growing reply text.
Tool rounds run silently; the final round streams token by token."""
if self.sol is None:
yield ("No solved schedule yet - click Solve first, then ask "
"me about the plan.")
return
checkpoint = len(self.messages)
self.messages.append({"role": "user", "content": user_text})
try:
for _ in range(8):
with client().messages.stream(
model=MODEL,
max_tokens=16000,
thinking={"type": "adaptive"},
system=ASSISTANT_SYSTEM,
tools=TOOLS,
messages=self.messages,
) as stream:
partial = ""
for text in stream.text_stream:
partial += text
yield partial
response = stream.get_final_message()
self.messages.append({"role": "assistant",
"content": response.content})
if response.stop_reason != "tool_use":
return
results = []
for block in response.content:
if block.type != "tool_use":
continue
try:
out = self._dispatch(block.name, dict(block.input))
results.append({"type": "tool_result",
"tool_use_id": block.id,
"content": json.dumps(out)})
except Exception as e:
results.append({"type": "tool_result",
"tool_use_id": block.id,
"content": f"Tool error: {e}",
"is_error": True})
self.messages.append({"role": "user", "content": results})
except (anthropic.APIError, TypeError) as e:
del self.messages[checkpoint:]
yield f"Error: {_friendly(e)}"
BRIEFING_SYSTEM = """\
You write morning briefings for insurance field adjusters. You receive
today's solved schedule as JSON. Write one short briefing per adjuster
with routes, in Markdown: a '## <id> <name>' heading, then a friendly
2-3 sentence overview of their day (how many stops, total driving,
when they're done), then a numbered stop list - each line with the
claim id, damage type, the time to be on site, and anything notable
(MUST-TODAY urgency, tight windows, long drives). Close each briefing
with one practical reminder if warranted. Plain language, no jargon,
no invented facts - use only what the JSON contains."""
def generate_briefings(assistant_state: "ScheduleAssistant") -> str:
"""One Claude call: turn the solved schedule into per-adjuster
morning briefings (Markdown)."""
if assistant_state.sol is None:
raise AssistantError("Solve a schedule first.")
schedule = assistant_state._schedule_dict(assistant_state.sol)
try:
with client().messages.stream(
model=MODEL,
max_tokens=16000,
system=BRIEFING_SYSTEM,
messages=[{"role": "user",
"content": json.dumps(schedule)}],
) as stream:
response = stream.get_final_message()
except (anthropic.APIError, TypeError) as e:
raise _friendly(e) from e
return "\n".join(b.text for b in response.content if b.type == "text")
|