cortad 0.2.2 → 0.3.0-rc.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,6 +7,7 @@
7
7
  import os
8
8
 
9
9
  _FILE = os.environ.get("CORTAD_TRACE_FILE")
10
+ _RULES = os.environ.get("CORTAD_RULES_FILE")
10
11
 
11
12
 
12
13
  def _install():
@@ -25,6 +26,159 @@ def _install():
25
26
  model_host = re.compile(r"(?:^|\.)(?:openai\.com|anthropic\.com|fireworks\.ai|openrouter\.ai|groq\.com|mistral\.ai|together\.xyz|together\.ai|deepseek\.com|cohere\.ai|cohere\.com|perplexity\.ai|x\.ai|openai\.azure\.com|cognitiveservices\.azure\.com|replicate\.com|huggingface\.co|cerebras\.ai|deepinfra\.com|novita\.ai|moonshot\.cn|dashscope\.aliyuncs\.com|bigmodel\.cn|ai-gateway\.vercel\.sh|gateway\.ai\.cloudflare\.com|helicone\.ai|portkey\.ai)$", re.I)
26
27
  model_path = re.compile(r"/(?:chat/completions|completions|responses|messages|embeddings)$|:(?:generateContent|streamGenerateContent)|/invoke(?:-with-response-stream)?$|/api/(?:chat|generate)$", re.I)
27
28
 
29
+ # The turn a message came in under, when the run tagged it (one opaque id per request), so a
30
+ # model call and its prompt can be pinned to the reply they produced while turns overlap.
31
+ turn_ok = re.compile(r"^[A-Za-z0-9:_.-]{1,80}$")
32
+
33
+ def turn_now():
34
+ req = ctx.get()
35
+ return req.get("turn") if req else None
36
+
37
+ # The customer's own rule sentences, written beside the trace by the run, so each model call can
38
+ # say which of them its prompt carried. Absent file, nothing is claimed. Reloaded on change.
39
+ rules_at = [-1.0]
40
+ rules = [None]
41
+ slot = re.compile(r"\{[^}]*\}|\$\{[^}]*\}|%[sd]|<[^>]{1,40}>")
42
+
43
+ def norm(s):
44
+ s = str(s or "")
45
+ s = re.sub(r"\\u([0-9a-fA-F]{4})", lambda m: chr(int(m.group(1), 16)), s)
46
+ s = re.sub(r"\\[nrt]", " ", s).replace('\\"', '"').replace("\\\\", "\\")
47
+ return re.sub(r"\s+", " ", s.lower()).strip()
48
+
49
+ def rules_now():
50
+ if not _RULES:
51
+ return None
52
+ try:
53
+ at = os.stat(_RULES).st_mtime
54
+ if at != rules_at[0]:
55
+ rules_at[0] = at
56
+ with open(_RULES, encoding="utf-8") as f:
57
+ raw = json.load(f)
58
+ out = []
59
+ for r in raw if isinstance(raw, list) else []:
60
+ if not isinstance(r, dict) or not isinstance(r.get("id"), str) or not isinstance(r.get("text"), str):
61
+ continue
62
+ parts = [p for p in (norm(x) for x in slot.split(r["text"])) if len(p) >= 12]
63
+ if parts:
64
+ out.append((r["id"], parts))
65
+ rules[0] = out
66
+ except Exception:
67
+ pass
68
+ return rules[0]
69
+
70
+ # What the app's tools answered, as the prompt of the next model call carries them (chat tool
71
+ # messages, responses function outputs, Anthropic tool_result blocks, Gemini functionResponse
72
+ # parts): the material a reply's facts rest on. Bounded per call.
73
+ tool_text, tools_max = 3000, 12
74
+
75
+ def text_of(v):
76
+ if isinstance(v, str):
77
+ return v
78
+ if isinstance(v, list):
79
+ return "\n".join(p for p in ((x.get("text") or x.get("content") or "") if isinstance(x, dict) else str(x or "") for x in v) if p)
80
+ if isinstance(v, dict):
81
+ try:
82
+ return json.dumps(v, ensure_ascii=False)
83
+ except (TypeError, ValueError):
84
+ return ""
85
+ return ""
86
+
87
+ def tools_in(sent):
88
+ body = parsed(sent) if isinstance(sent, str) else None
89
+ if not isinstance(body, dict):
90
+ return None
91
+ out, names = [], {}
92
+
93
+ def add(name, text):
94
+ t = text_of(text)[:tool_text]
95
+ if t.strip() and len(out) < tools_max and all(o["text"] != t for o in out):
96
+ out.append({"name": str(name or "")[:80], "text": t})
97
+
98
+ messages = body.get("messages") if isinstance(body.get("messages"), list) else []
99
+ for m in messages:
100
+ if not isinstance(m, dict):
101
+ continue
102
+ for c in m.get("tool_calls") or []:
103
+ if isinstance(c, dict) and c.get("id") and isinstance(c.get("function"), dict):
104
+ names[c["id"]] = c["function"].get("name")
105
+ if isinstance(m.get("content"), list):
106
+ for c in m["content"]:
107
+ if isinstance(c, dict) and c.get("type") == "tool_use" and c.get("id"):
108
+ names[c["id"]] = c.get("name")
109
+ for m in messages:
110
+ if not isinstance(m, dict):
111
+ continue
112
+ if m.get("role") in ("tool", "function"):
113
+ add(m.get("name") or names.get(m.get("tool_call_id")), m.get("content"))
114
+ if isinstance(m.get("content"), list):
115
+ for c in m["content"]:
116
+ if isinstance(c, dict) and c.get("type") == "tool_result":
117
+ add(names.get(c.get("tool_use_id")), c.get("content"))
118
+ items = body.get("input") if isinstance(body.get("input"), list) else []
119
+ for it in items:
120
+ if isinstance(it, dict) and it.get("type") == "function_call" and it.get("call_id"):
121
+ names[it["call_id"]] = it.get("name")
122
+ for it in items:
123
+ if isinstance(it, dict) and it.get("type") == "function_call_output":
124
+ add(names.get(it.get("call_id")), it.get("output"))
125
+ for c in body.get("contents") if isinstance(body.get("contents"), list) else []:
126
+ for p in c.get("parts") if isinstance(c, dict) and isinstance(c.get("parts"), list) else []:
127
+ if isinstance(p, dict) and isinstance(p.get("functionResponse"), dict):
128
+ add(p["functionResponse"].get("name"), p["functionResponse"].get("response"))
129
+ return out or None
130
+
131
+ # A framework that folds its tools' answers into the next prompt as plain text sends no tool
132
+ # message to find: within one turn, what a later call's prompt carries that the call before did
133
+ # not, outside the model's own words, is that material. The first call holds the customer's
134
+ # message and the system prompt, present in every later call, so neither is ever counted.
135
+ seen_by_turn = {}
136
+
137
+ def texts_of(body):
138
+ out = set()
139
+
140
+ def take(v):
141
+ t = text_of(v).strip()
142
+ if t:
143
+ out.add(t[:tool_text])
144
+ for m in body.get("messages") if isinstance(body.get("messages"), list) else []:
145
+ if isinstance(m, dict) and m.get("role") != "assistant":
146
+ take(m.get("content"))
147
+ for it in body.get("input") if isinstance(body.get("input"), list) else []:
148
+ if isinstance(it, dict) and it.get("role") != "assistant" and it.get("type") != "function_call":
149
+ take(it.get("content") if it.get("content") is not None else it.get("output"))
150
+ if isinstance(body.get("system"), str):
151
+ take(body["system"])
152
+ for c in body.get("contents") if isinstance(body.get("contents"), list) else []:
153
+ if isinstance(c, dict) and c.get("role") != "model":
154
+ for p in c.get("parts") if isinstance(c.get("parts"), list) else []:
155
+ if isinstance(p, dict) and isinstance(p.get("text"), str):
156
+ take(p["text"])
157
+ return out
158
+
159
+ def material_in(sent, turn):
160
+ if not turn:
161
+ return None
162
+ body = parsed(sent) if isinstance(sent, str) else None
163
+ if not isinstance(body, dict):
164
+ return None
165
+ now = texts_of(body)
166
+ before = seen_by_turn.get(turn)
167
+ seen_by_turn[turn] = now
168
+ if len(seen_by_turn) > 200:
169
+ seen_by_turn.pop(next(iter(seen_by_turn)))
170
+ if before is None:
171
+ return None
172
+ fresh = [{"name": "", "text": t} for t in now if t not in before][:tools_max]
173
+ return fresh or None
174
+
175
+ def rules_in(sent):
176
+ found = rules_now()
177
+ if found is None:
178
+ return None
179
+ body = norm(sent)
180
+ return [rid for rid, parts in found if all(p in body for p in parts)]
181
+
28
182
  def write(row):
29
183
  try:
30
184
  fd = os.open(_FILE, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
@@ -33,6 +187,17 @@ def _install():
33
187
  except OSError:
34
188
  pass
35
189
 
190
+ # Every other outbound call: the host and what it answered, never a byte of it. A search
191
+ # provider over its limit for a whole run was invisible until this row existed.
192
+ def dep(url, status, code=None):
193
+ try:
194
+ host = (urlsplit(str(url)).hostname or "").lower()
195
+ if not host or host in ("localhost", "127.0.0.1", "::1"):
196
+ return
197
+ turn = turn_now()
198
+ write({"dep": {"at": int(time.time() * 1000), "host": host[:253], "status": int(status or 0), **({"code": str(code)[:40]} if code else {}), **({"turn": turn} if turn else {})}})
199
+ except Exception:
200
+ pass
36
201
  def is_model_call(url):
37
202
  try:
38
203
  parts = urlsplit(str(url))
@@ -141,19 +306,31 @@ def _install():
141
306
  except Exception:
142
307
  return ""
143
308
 
144
- def meter(url, sent, status, kind, raw):
309
+ def meter(url, sent, status, kind, raw, turn=None):
145
310
  try:
146
311
  tokens, model = read_reply(kind, (raw or "")[:reply_max])
147
312
  parts = urlsplit(str(url))
148
313
  row = {"at": int(time.time() * 1000), "host": parts.netloc.replace(":443", ""), "model": str(asked_for(url, sent) or model or "")[:160],
149
314
  "status": int(status or 0), "usage": tokens is not None}
150
315
  row.update(tokens or {"promptTokens": 0, "cachedTokens": 0, "completionTokens": 0})
316
+ turn = turn or turn_now()
317
+ if turn:
318
+ row["turn"] = turn
319
+ as_text = sent if isinstance(sent, str) else text(sent)
320
+ found = rules_in(as_text)
321
+ if found is not None:
322
+ row["rules"] = found
323
+ tools = tools_in(as_text) or material_in(as_text, turn)
324
+ if tools:
325
+ row["tools"] = tools
151
326
  write({"call": row})
152
327
  except Exception:
153
328
  pass
154
329
 
155
330
  def started(method, path, headers):
156
- return {"method": method, "path": path, "headers": headers, "chunks": [], "size": 0, "noted": False}
331
+ turn = headers.pop("x-cortad-turn", None)
332
+ return {"method": method, "path": path, "headers": headers, "chunks": [], "size": 0, "noted": False,
333
+ "turn": turn if isinstance(turn, str) and turn_ok.match(turn) else None}
157
334
 
158
335
  def keep(req, chunk):
159
336
  if chunk and req["size"] < limit:
@@ -249,16 +426,154 @@ def _install():
249
426
  wrapped._brainsless = True
250
427
  return wrapped
251
428
 
429
+ # The app's own route table, read off the app object the server was handed, so the run knows
430
+ # every door the app has before a message is sent. Read only: nothing here changes the app.
431
+ route_max, openapi_max = 400, 8 * 1024 * 1024
432
+ verbs = ("GET", "POST", "PUT", "PATCH", "DELETE")
433
+ served_port = [None]
434
+
435
+ def template(path):
436
+ # One syntax for every framework's path parameters: OpenAPI's {name}.
437
+ p = re.sub(r"\(\?P<(\w+)>(?:[^()]|\([^()]*\))*\)", r"{\1}", str(path))
438
+ p = re.sub(r"<(?:[^<>:]+:)?(\w+)>", r"{\1}", p)
439
+ p = re.sub(r"\{(\w+):[^{}]*\}", r"{\1}", p)
440
+ p = re.sub(r"(?:\$|\\Z)$", "", re.sub(r"^\^", "", p))
441
+ return p.replace("\\.", ".").replace("/?", "/")
442
+
443
+ def source_of(fn):
444
+ import inspect
445
+ try:
446
+ fn = inspect.unwrap(fn)
447
+ except Exception:
448
+ pass
449
+ code = getattr(fn, "__code__", None)
450
+ path = code.co_filename if code else getattr(sys.modules.get(getattr(fn, "__module__", None) or ""), "__file__", None) or ""
451
+ rel = os.path.relpath(path, os.getcwd()) if path else ""
452
+ theirs = rel and not rel.startswith("..") and "site-packages" not in rel and "dist-packages" not in rel
453
+ return (rel if theirs else ""), str(getattr(fn, "__name__", "") or "")
454
+
455
+ def add(out, methods, path, fn):
456
+ # Methods unknown: the two a door is asked with.
457
+ file, handler = source_of(fn) if fn is not None else ("", "")
458
+ for m in ("POST", "GET") if methods is None else methods:
459
+ m = str(m).upper()
460
+ if m not in ("HEAD", "OPTIONS") and len(out) < route_max:
461
+ out.append({"method": m, "path": ("/" + template(path).lstrip("/"))[:1024], "file": file[:512], "handler": handler[:200]})
462
+
463
+ def class_methods(cls):
464
+ return [m for m in verbs if callable(getattr(cls, m.lower(), None))]
465
+
466
+ def starlette_routes(routes, prefix, out):
467
+ for r in routes or []:
468
+ path = prefix + (getattr(r, "path", "") or "")
469
+ sub = getattr(r, "routes", None)
470
+ # FastAPI 0.14x keeps an included router whole and resolves its routes on demand.
471
+ if callable(getattr(r, "effective_route_contexts", None)):
472
+ for c in r.effective_route_contexts():
473
+ if c.starlette_route is not None:
474
+ starlette_routes([c.starlette_route], prefix, out)
475
+ else:
476
+ add(out, c.methods, prefix + c.path, c.endpoint)
477
+ elif sub is not None:
478
+ starlette_routes(sub, path, out)
479
+ elif getattr(r, "endpoint", None) is not None and "WebSocket" not in type(r).__name__:
480
+ fn = r.endpoint
481
+ add(out, getattr(r, "methods", None) or (class_methods(fn) if isinstance(fn, type) else None), path, fn)
482
+
483
+ def flask_routes(app, out):
484
+ for rule in app.url_map.iter_rules():
485
+ if rule.endpoint != "static" and not rule.endpoint.endswith(".static"):
486
+ add(out, rule.methods, rule.rule, app.view_functions.get(rule.endpoint))
487
+
488
+ def django_routes(_app, out):
489
+ from django.urls import URLResolver, get_resolver
490
+
491
+ def walk(patterns, prefix):
492
+ for p in patterns:
493
+ path = prefix + template(str(p.pattern))
494
+ if isinstance(p, URLResolver):
495
+ walk(p.url_patterns, path)
496
+ continue
497
+ cb = p.callback
498
+ actions = getattr(cb, "actions", None)
499
+ cls = getattr(cb, "cls", None) or getattr(cb, "view_class", None)
500
+ add(out, list(actions) if isinstance(actions, dict) else class_methods(cls) if cls else None, path, cls or cb)
501
+
502
+ walk(get_resolver().url_patterns, "/")
503
+
504
+ def litestar_routes(app, out):
505
+ for r in app.routes:
506
+ for h in getattr(r, "route_handlers", None) or []:
507
+ methods = [m for m in getattr(h, "http_methods", ()) if str(m).upper() in verbs]
508
+ if methods:
509
+ add(out, methods, r.path, getattr(h, "fn", None))
510
+
511
+ def aiohttp_routes(app, out):
512
+ for r in app.router.routes():
513
+ add(out, None if r.method == "*" else [r.method], r.resource.canonical if r.resource else "", r.handler)
514
+
515
+ def asgi_routes(app, out):
516
+ starlette_routes(app.routes, "", out)
517
+
518
+ walkers = (("fastapi", "routes", asgi_routes), ("starlette", "routes", asgi_routes),
519
+ ("quart", "url_map", flask_routes), ("flask", "url_map", flask_routes),
520
+ ("litestar", "routes", litestar_routes), ("aiohttp", "router", aiohttp_routes),
521
+ ("django", None, django_routes))
522
+
523
+ def app_in(obj):
524
+ # Servers hand the app over inside their own middleware, and each layer keeps the next.
525
+ for _ in range(12):
526
+ if obj is None:
527
+ break
528
+ tops = {c.__module__.split(".")[0] for c in type(obj).__mro__}
529
+ for name, attr, walk in walkers:
530
+ if name in tops and (attr is None or hasattr(obj, attr)):
531
+ return name, obj, walk
532
+ inner = getattr(obj, "app", None)
533
+ obj = inner if inner is not None else getattr(obj, "application", None)
534
+ return None, None, None
535
+
536
+ def openapi_of(name, app):
537
+ try:
538
+ if name == "fastapi":
539
+ # openapi() caches what it builds; the app's own cache is put back as it was.
540
+ kept = app.openapi_schema
541
+ try:
542
+ doc = app.openapi()
543
+ finally:
544
+ app.openapi_schema = kept
545
+ elif name == "litestar":
546
+ doc = app.openapi_schema.to_schema()
547
+ else:
548
+ return None
549
+ return doc if isinstance(doc, dict) and len(json.dumps(doc)) <= openapi_max else None
550
+ except Exception:
551
+ return None
552
+
553
+ def publish_routes(app, port):
554
+ name, found, out = None, None, []
555
+ try:
556
+ name, found, walk = app_in(app)
557
+ if walk:
558
+ walk(found, out)
559
+ except Exception:
560
+ pass
561
+ port = port if isinstance(port, int) and not isinstance(port, bool) else None
562
+ write({"routes": {"framework": name or "unknown", "port": port, "routes": out, "openapi": openapi_of(name, found)}})
563
+
252
564
  # What is patched, once the module it lives in has been imported by the app itself.
253
565
  def patch_uvicorn(module):
254
566
  load = module.Config.load
255
567
 
256
568
  def loaded(self, *a, **k):
257
569
  out = load(self, *a, **k)
570
+ # Under gunicorn, uvicorn's own port is its default and the socket is gunicorn's.
571
+ port = served_port[0] or getattr(self, "port", None)
572
+ publish_routes(self.loaded_app, port)
258
573
  if not isinstance(self.loaded_app, Asgi):
259
574
  self.loaded_app = Asgi(self.loaded_app)
260
- if isinstance(getattr(self, "port", None), int):
261
- write({"listen": self.port, "pid": os.getpid()})
575
+ if isinstance(port, int):
576
+ write({"listen": port, "pid": os.getpid()})
262
577
  return out
263
578
 
264
579
  module.Config.load = loaded
@@ -269,6 +584,7 @@ def _install():
269
584
  def run(hostname, port, application, *a, **k):
270
585
  if isinstance(port, int):
271
586
  write({"listen": port, "pid": os.getpid()})
587
+ publish_routes(application, port)
272
588
  return run_simple(hostname, port, wsgi(application), *a, **k)
273
589
 
274
590
  module.run_simple = run
@@ -281,6 +597,54 @@ def _install():
281
597
 
282
598
  module.WSGIHandler.__call__ = called
283
599
 
600
+ def patch_django_server(module):
601
+ run = module.run
602
+
603
+ def running(addr, port, wsgi_handler, *a, **k):
604
+ publish_routes(wsgi_handler, port)
605
+ return run(addr, port, wsgi_handler, *a, **k)
606
+
607
+ module.run = running
608
+
609
+ def patch_gunicorn(module):
610
+ load = module.Worker.load_wsgi
611
+
612
+ def loaded(self):
613
+ out = load(self)
614
+ try:
615
+ bound = [s.getsockname() for s in self.sockets]
616
+ served_port[0] = next((at[1] for at in bound if isinstance(at, tuple)), None)
617
+ except Exception:
618
+ pass
619
+ publish_routes(self.wsgi, served_port[0])
620
+ return out
621
+
622
+ module.Worker.load_wsgi = loaded
623
+
624
+ def patch_hypercorn(module):
625
+ serve = module.worker_serve
626
+
627
+ def serving(app, config, *a, **k):
628
+ port = None
629
+ try:
630
+ port = int(str(config.bind[0]).rsplit(":", 1)[1])
631
+ except Exception:
632
+ pass
633
+ publish_routes(app, port)
634
+ return serve(app, config, *a, **k)
635
+
636
+ module.worker_serve = serving
637
+
638
+ def patch_aiohttp_web(module):
639
+ run_app = module.run_app
640
+
641
+ def run(app, *a, **k):
642
+ if isinstance(app, module.Application):
643
+ publish_routes(app, k.get("port") or 8080)
644
+ return run_app(app, *a, **k)
645
+
646
+ module.run_app = run
647
+
284
648
  def sent_of(request):
285
649
  try:
286
650
  return text(request.content)
@@ -330,6 +694,7 @@ def _install():
330
694
 
331
695
  def watch(request, response, sent):
332
696
  if not is_model_call(request.url):
697
+ dep(request.url, response.status_code)
333
698
  return response
334
699
  kind = response.headers.get("content-type", "")
335
700
  if getattr(response, "_content", None) is not None:
@@ -356,9 +721,11 @@ def _install():
356
721
  pass
357
722
  try:
358
723
  response = _send(self, request, *a, **k)
359
- except Exception:
724
+ except Exception as err:
360
725
  if is_model_call(request.url):
361
726
  meter(request.url, sent, 0, "", "")
727
+ else:
728
+ dep(request.url, 0, type(err).__name__)
362
729
  raise
363
730
  try:
364
731
  return watch(request, response, sent)
@@ -374,9 +741,11 @@ def _install():
374
741
  pass
375
742
  try:
376
743
  response = await _send(self, request, *a, **k)
377
- except Exception:
744
+ except Exception as err:
378
745
  if is_model_call(request.url):
379
746
  meter(request.url, sent, 0, "", "")
747
+ else:
748
+ dep(request.url, 0, type(err).__name__)
380
749
  raise
381
750
  try:
382
751
  return watch(request, response, sent)
@@ -392,8 +761,15 @@ def _install():
392
761
  note(request.url, request.body)
393
762
  except Exception:
394
763
  pass
395
- response = send(self, request, *a, **k)
396
764
  try:
765
+ response = send(self, request, *a, **k)
766
+ except Exception as err:
767
+ if not is_model_call(request.url):
768
+ dep(request.url, 0, type(err).__name__)
769
+ raise
770
+ try:
771
+ if not is_model_call(request.url):
772
+ dep(request.url, response.status_code)
397
773
  if is_model_call(request.url):
398
774
  # A streamed reply is counted as a call whose counts were not read.
399
775
  raw = response.content.decode("utf-8", "replace") if getattr(response, "_content_consumed", False) else ""
@@ -413,9 +789,16 @@ def _install():
413
789
  note(str_or_url, body)
414
790
  except Exception:
415
791
  pass
416
- response = await request(self, method, str_or_url, *a, **k)
792
+ try:
793
+ response = await request(self, method, str_or_url, *a, **k)
794
+ except Exception as err:
795
+ if not is_model_call(str_or_url):
796
+ dep(str_or_url, 0, type(err).__name__)
797
+ raise
417
798
  if is_model_call(str_or_url):
418
- response._cortad = (str_or_url, text(body))
799
+ response._cortad = (str_or_url, text(body), turn_now())
800
+ else:
801
+ dep(str_or_url, response.status)
419
802
  return response
420
803
 
421
804
  # Metered when your app reads the reply, or, for one it streams, when the reply is let go.
@@ -426,14 +809,14 @@ def _install():
426
809
  call = getattr(self, "_cortad", None)
427
810
  if call:
428
811
  self._cortad = None
429
- meter(call[0], call[1], self.status, self.headers.get("content-type", ""), (raw or b"").decode("utf-8", "replace"))
812
+ meter(call[0], call[1], self.status, self.headers.get("content-type", ""), (raw or b"").decode("utf-8", "replace"), call[2])
430
813
  return raw
431
814
 
432
815
  def release_kept(self, *a, **k):
433
816
  call = getattr(self, "_cortad", None)
434
817
  if call:
435
818
  self._cortad = None
436
- meter(call[0], call[1], self.status, self.headers.get("content-type", ""), "")
819
+ meter(call[0], call[1], self.status, self.headers.get("content-type", ""), "", call[2])
437
820
  return release(self, *a, **k)
438
821
 
439
822
  module.ClientSession._request = requested
@@ -441,6 +824,8 @@ def _install():
441
824
  module.ClientResponse.release = release_kept
442
825
 
443
826
  exact = {"uvicorn.config": patch_uvicorn, "werkzeug.serving": patch_werkzeug, "django.core.handlers.wsgi": patch_django,
827
+ "django.core.servers.basehttp": patch_django_server, "gunicorn.workers.base": patch_gunicorn,
828
+ "hypercorn.asyncio.run": patch_hypercorn, "hypercorn.trio.run": patch_hypercorn, "aiohttp.web": patch_aiohttp_web,
444
829
  "requests.sessions": patch_requests, "aiohttp.client": patch_aiohttp}
445
830
  # By family, not by name: the OpenAI SDK moved to a renamed copy of httpx (httpx2), and a patch
446
831
  # keyed on "httpx" alone saw none of its calls.
@@ -0,0 +1,91 @@
1
+ import { at, clip, has, num, pack, pageOf, plural, SIDE } from "./words.mjs";
2
+
3
+ // What Cortad read of the repository: the walkthrough `status` carries, and one section in full
4
+ // for `status` with show. Both use the same lines, so the full list reads like the walkthrough.
5
+ //
6
+ // The full list comes back from GET /mcp/status?show=<name>: read.rules.all, read.machine.misses
7
+ // complete with read.machine.mets, read.journeys.all, read.doors.all, read.trials.all. Where the API
8
+ // has not sent it, what it did send is listed with "n of N listed".
9
+ export const SHOW = ["rules", "standards", "journeys", "endpoints", "trials"];
10
+
11
+ const rulesHead = (rules) => {
12
+ const files = Array.isArray(rules.files) ? rules.files.length : rules.files;
13
+ return `Rules in the code: ${num(rules.count)}${has(files) ? `, in ${plural(files, "file")}` : ""}.`;
14
+ };
15
+ const ruleLine = (e) => ` ${at(e.path, e.line)} "${clip(e.text, 300)}"`;
16
+ const headOf = (label, parts) => (parts.some(Boolean) ? `${label}: ${parts.filter(Boolean).join(", ")}.` : `${label}:`);
17
+ const standardsHead = (m) => headOf("Engineering standards", [has(m.decided) && `${num(m.decided)} decided`, has(m.met) && `${num(m.met)} met`, has(m.missed) && `${num(m.missed)} missed`]);
18
+ const standardLine = (x) => ` ${x.path ? `${at(x.path, x.line)} ` : ""}${x.standard}${x.detail ? `: ${clip(x.detail, 240)}` : ""}${x.decidedBy ? ` (decided by ${x.decidedBy === "code" ? "code" : "a model"})` : ""}`;
19
+ const trialsHead = (t) => headOf("Trials", [has(t.written) && `${num(t.written)} written`, has(t.playable) && `${num(t.playable)} playable`]);
20
+ const heldWhy = (h) => `${SIDE[h.side] ? `, ${SIDE[h.side]}` : ""}${h.why ? `: ${clip(h.why, 300)}` : ""}`;
21
+ const named = (label, group, max = 12) => {
22
+ if (!group) return null;
23
+ const names = group.names ?? [];
24
+ const more = (group.count ?? names.length) - Math.min(names.length, max);
25
+ const list = names.slice(0, max).join(", ");
26
+ return `${label} (${num(group.count ?? names.length)})${list ? `: ${list}${more > 0 ? `, and ${more} more` : ""}` : ""}.`;
27
+ };
28
+ // "Journeys (6):" when every one is here, "Journeys (6), 4 listed:" when not.
29
+ const counted = (label, count, n) => `${label} (${num(count ?? n)})${has(count) && n < count ? `, ${num(n)} listed` : ""}:`;
30
+
31
+ // The walkthrough, in the order the person is walked through it.
32
+ export function readLines(r) {
33
+ const lines = ["What Cortad read:"];
34
+ if (r.rules) {
35
+ const examples = (r.rules.examples ?? []).slice(0, 3);
36
+ lines.push(`${rulesHead(r.rules)}${examples.length ? ` ${examples.length} of them:` : ""}`, ...examples.map(ruleLine));
37
+ }
38
+ for (const line of [named("Journeys", r.journeys), named("Simulated users", r.profiles), named("Endpoints", r.doors)]) if (line) lines.push(line);
39
+ const m = r.machine;
40
+ if (m) {
41
+ const misses = m.misses ?? [];
42
+ lines.push(standardsHead(m), ...misses.slice(0, 12).map(standardLine));
43
+ if (misses.length > 12) lines.push(` and ${misses.length - 12} more.`);
44
+ }
45
+ const q = r.questions;
46
+ if (q) lines.push(`Questions: ${num(q.total)}${has(q.everywhere) ? `; ${num(q.everywhere)} asked in every conversation` : ""}${has(q.placed) ? `, ${num(q.placed)} placed in the situations they fit` : ""}.`);
47
+ const t = r.trials;
48
+ if (t) lines.push(trialsHead(t), ...(t.held ?? []).map((h) => ` ${h.surface}: ${plural(h.n, "trial")} held back${heldWhy(h)}`));
49
+ return lines;
50
+ }
51
+
52
+ export function showText(d, show, page = 1) {
53
+ if (!d.read) return "Cortad is still reading this repository.";
54
+ const { head, lines, continued } = sectionOf(d.read, show);
55
+ return pageOf(pack(head, lines, continued), page, (n) => `status with show ${show} and page ${n}`);
56
+ }
57
+
58
+ function sectionOf(r, show) {
59
+ switch (show) {
60
+ case "rules": {
61
+ const rules = r.rules ?? { count: 0 };
62
+ const all = rules.all ?? rules.examples ?? [];
63
+ return { head: `${rulesHead(rules)}${all.length < rules.count ? ` ${num(all.length)} of ${num(rules.count)} listed.` : ""}`, lines: all.map(ruleLine), continued: "Rules in the code, continued." };
64
+ }
65
+ case "standards": {
66
+ const m = r.machine ?? {};
67
+ const misses = m.misses ?? [];
68
+ const mets = m.mets ?? [];
69
+ return {
70
+ head: standardsHead(m),
71
+ lines: [misses.length && counted("Missed", m.missed, misses.length), ...misses.map(standardLine), mets.length && counted("Met", m.met, mets.length), ...mets.map(standardLine)].filter(Boolean),
72
+ continued: "Engineering standards, continued.",
73
+ };
74
+ }
75
+ case "journeys": {
76
+ const all = r.journeys?.all ?? (r.journeys?.names ?? []).map((name) => ({ name }));
77
+ return { head: counted("Journeys", r.journeys?.count, all.length), lines: all.map((j) => ` ${j.name}${j.steps?.length ? `: ${clip(j.steps.join("; "), 600)}` : ""}`), continued: "Journeys, continued." };
78
+ }
79
+ case "endpoints": {
80
+ const all = r.doors?.all ?? (r.doors?.names ?? []).map((name) => ({ name }));
81
+ return { head: counted("Endpoints", r.doors?.count, all.length), lines: all.map((e) => ` ${e.name}${e.path ? `: ${[e.method, e.path].filter(Boolean).join(" ")}` : ""}`), continued: "Endpoints, continued." };
82
+ }
83
+ case "trials":
84
+ default: {
85
+ const t = r.trials ?? {};
86
+ const all = t.all ?? (t.held ?? []).map((h) => ({ surface: h.surface, held: h.n, why: h.why, side: h.side }));
87
+ const line = (x) => ` ${x.surface}: ${[has(x.written) && `${num(x.written)} written`, has(x.playable) && `${num(x.playable)} playable`, x.held && `${num(x.held)} held back`].filter(Boolean).join(", ")}${x.held ? heldWhy(x) : ""}`;
88
+ return { head: trialsHead(t), lines: all.map(line), continued: "Trials, continued." };
89
+ }
90
+ }
91
+ }