@manhquy/flow-skill 0.1.0-rc.1

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.
Files changed (76) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/LICENSE +21 -0
  3. package/README.md +152 -0
  4. package/README_VN.md +134 -0
  5. package/SECURITY.md +69 -0
  6. package/bin/cli.mjs +382 -0
  7. package/package.json +52 -0
  8. package/skills/flow/SKILL.md +234 -0
  9. package/skills/flow/_templates/00-idea.md +16 -0
  10. package/skills/flow/_templates/00-inspect.md +42 -0
  11. package/skills/flow/_templates/01-research.md +59 -0
  12. package/skills/flow/_templates/02-scope.md +71 -0
  13. package/skills/flow/_templates/03-prd.md +57 -0
  14. package/skills/flow/_templates/04-adr.md +21 -0
  15. package/skills/flow/_templates/05-contract.md +59 -0
  16. package/skills/flow/_templates/card.md +26 -0
  17. package/skills/flow/_templates/constitution.md +22 -0
  18. package/skills/flow/eval/fixtures/f01a/flow/01-research.md +77 -0
  19. package/skills/flow/eval/fixtures/f01b/flow/01-research.md +75 -0
  20. package/skills/flow/eval/fixtures/f02a/flow/02-scope.md +56 -0
  21. package/skills/flow/eval/fixtures/f02b/flow/02-scope.md +65 -0
  22. package/skills/flow/eval/fixtures/fcda/.flow/.gitkeep +0 -0
  23. package/skills/flow/eval/fixtures/fcda/cards/C-001.md +46 -0
  24. package/skills/flow/eval/fixtures/fcdb/.flow/.gitkeep +0 -0
  25. package/skills/flow/eval/fixtures/fcdb/cards/C-001.md +35 -0
  26. package/skills/flow/eval/manifest.tsv +7 -0
  27. package/skills/flow/harness/README.md +117 -0
  28. package/skills/flow/harness/_db.py +228 -0
  29. package/skills/flow/harness/_domain.py +161 -0
  30. package/skills/flow/harness/_presence.py +109 -0
  31. package/skills/flow/harness/flow_harness.py +1103 -0
  32. package/skills/flow/harness/repo_map.py +93 -0
  33. package/skills/flow/harness/schema/001-init.sql +129 -0
  34. package/skills/flow/harness/schema/002-story-verify.sql +9 -0
  35. package/skills/flow/harness/schema/003-tool-registry.sql +15 -0
  36. package/skills/flow/harness/schema/004-intervention.sql +15 -0
  37. package/skills/flow/harness/schema/005-tool-extensions.sql +20 -0
  38. package/skills/flow/harness/schema/009-accessed-count.sql +11 -0
  39. package/skills/flow/harness/schema/010-usage-event.sql +42 -0
  40. package/skills/flow/harness/schema/011-usage-gate-reason.sql +8 -0
  41. package/skills/flow/harness/schema/012-usage-ephemeral.sql +10 -0
  42. package/skills/flow/law/CLAUDE.md +140 -0
  43. package/skills/flow/law/DESIGN.md +141 -0
  44. package/skills/flow/law/RETRO.md +13 -0
  45. package/skills/flow/playbooks/README.md +28 -0
  46. package/skills/flow/playbooks/cloudflare-ai-gateway-qwen.md +346 -0
  47. package/skills/flow/playbooks/docker-deploy-stale-cache.md +32 -0
  48. package/skills/flow/playbooks/rag-lite-postgres-cloudflare.md +192 -0
  49. package/skills/flow/playbooks/windows-gitbash-jsonl-telemetry.md +57 -0
  50. package/skills/flow/references/adversarial-review.md +197 -0
  51. package/skills/flow/references/agent-detection.md +88 -0
  52. package/skills/flow/references/agent-stage-mapping.md +114 -0
  53. package/skills/flow/references/antigravity-integration.md +92 -0
  54. package/skills/flow/references/auto-run.md +81 -0
  55. package/skills/flow/references/claudekit-skills.md +128 -0
  56. package/skills/flow/references/codex-integration.md +104 -0
  57. package/skills/flow/references/command-dispatch.md +54 -0
  58. package/skills/flow/references/debt-and-halts.md +41 -0
  59. package/skills/flow/references/design-review-checklist.md +42 -0
  60. package/skills/flow/references/gate-eval.md +186 -0
  61. package/skills/flow/references/gate-rules.md +171 -0
  62. package/skills/flow/references/ground-truth-gates.md +49 -0
  63. package/skills/flow/references/loop-harness-2026-principles.md +52 -0
  64. package/skills/flow/references/mode-work.md +37 -0
  65. package/skills/flow/references/project-types.md +42 -0
  66. package/skills/flow/references/stage-state-machine.md +47 -0
  67. package/skills/flow/references/ui-patterns-tcr.md +49 -0
  68. package/skills/flow/runner/flow.cmd +18 -0
  69. package/skills/flow/runner/flow.sh +3297 -0
  70. package/skills-manifest.json +70 -0
  71. package/src/constants.mjs +65 -0
  72. package/src/detect.mjs +36 -0
  73. package/src/help.mjs +42 -0
  74. package/src/installer.mjs +231 -0
  75. package/src/prompt.mjs +60 -0
  76. package/src/tty.mjs +11 -0
@@ -0,0 +1,28 @@
1
+ # Playbooks — paid-for stack knowledge
2
+
3
+ One playbook = one stack/integration, written AFTER it worked in a real project
4
+ (manual-first: the playbook wraps a successful run, never speculation). They exist so
5
+ the next card touching that stack starts from the gotchas instead of rediscovering them.
6
+
7
+ ## Rules
8
+
9
+ 1. **Read before build.** Any card whose scope touches a stack with a playbook here —
10
+ the builder reads the playbook FIRST. In auto runs, the planner includes the relevant
11
+ playbook in the subagent's brief (alongside the card + contract + CLAUDE.md).
12
+ 2. **Harvest after build.** When a card pays for a non-obvious lesson (a gotcha, a quirk,
13
+ a smoke test that saved the architecture), capture it: update the existing playbook or
14
+ add a new one. The card's review isn't done until the lesson is filed.
15
+ 3. **Shape:** name `<stack>-<thing>.md`; start with "When to use this"; put the critical
16
+ gotcha at the TOP (the thing that breaks people first); include runnable smoke tests;
17
+ end with provenance (which project, when).
18
+ 4. **Smoke test before architecture.** If a playbook ships smoke tests, run them BEFORE
19
+ committing to a design that depends on that stack behaving (see the Qwen tool-calling
20
+ gotcha — caught live, before the agent framework was built on sand).
21
+
22
+ ## Index
23
+
24
+ | Playbook | When to use |
25
+ |---|---|
26
+ | [cloudflare-ai-gateway-qwen.md](cloudflare-ai-gateway-qwen.md) | LLM feature in a Python/FastAPI backend via CF AI Gateway → Workers AI Qwen. CRITICAL: Qwen returns no real tool_calls — single structured-JSON calls, not agent frameworks. |
27
+ | [docker-deploy-stale-cache.md](docker-deploy-stale-cache.md) | Deployed route 404s though merged+deployed: layer cache served a stale image. Check live `/openapi.json` BEFORE debugging code; fix Dockerfile layer order. |
28
+ | [windows-gitbash-jsonl-telemetry.md](windows-gitbash-jsonl-telemetry.md) | Shell-writes / Python-reads a JSONL log on Git Bash for Windows. CRITICAL: bash `$HOME` != Python `expanduser` (USERPROFILE); `$TEMP` is `C:\` but `$ROOT` is `/c/`; no atomic append for files; flock absent. |
@@ -0,0 +1,346 @@
1
+ # Playbook: LLM agent in a Python/FastAPI backend via Cloudflare AI Gateway → Qwen (Workers AI)
2
+
3
+ Replicate in ~15 min. Copy-paste the skeleton, run the smoke tests, ship.
4
+
5
+ ## When to use this
6
+ You want an LLM feature (classification, structured extraction, draft generation, summarization) in a backend, routed through **Cloudflare AI Gateway** for cost observability + a single seam, hitting **Workers AI** models (e.g. Qwen). No Anthropic/OpenAI key — just a **Cloudflare API token**.
7
+
8
+ ---
9
+
10
+ ## DECISION RULE — single call vs. deepagents agent (read first)
11
+ **Use a single structured-JSON call** (cheap model like `@cf/qwen/qwen2.5-coder-32b-instruct`) for **classify / extract / draft / summarize** — any one-shot transform. This is the common case; most "AI features" in a CRUD app are single calls.
12
+
13
+ **Use deepagents** (`@cf/qwen/qwen3-30b-a3b-fp8` + the content shim, see "deepagents" sections below) ONLY when the task needs **multi-step planning + tool use + sub-agents** — investigate-then-act, multi-source recap, retrieval-augmented chat.
14
+
15
+ **Don't pay the agent tax for a one-shot transform.** The ab-tickets triage feature is a single call, and that was the right call.
16
+
17
+ ---
18
+
19
+ ## ⚠️ THE CRITICAL GOTCHA — read first
20
+ **Qwen on Workers AI does NOT return OpenAI-style `tool_calls`.**
21
+
22
+ When you pass `tools` + `tool_choice`, the response has `tool_calls: null` and the model emits the "function call" as TEXT in `content`:
23
+
24
+ ```
25
+ <function-name>triage</function-name>
26
+ <args-json-object>{"category":"repair","sentiment":"angry"}</args-json-object>
27
+ ```
28
+
29
+ This **breaks any agentic framework that depends on real tool-calling** (deepagents / LangGraph tool loops, OpenAI-tool-calling clients). We caught this with a live smoke test *before* committing to the architecture.
30
+
31
+ **LESSON:** If your task is really "get structured output from ONE call" (classify / extract / draft / summarize), do NOT use a multi-step tool-calling agent framework with Qwen. Use a **single structured-JSON prompt** instead. Same user value, no non-determinism, no broken-tool-calling dependency. (This is the "re-architect C→B" move.)
32
+
33
+ **Always run the tool-calling smoke test (below) BEFORE building on any model you haven't used through this gateway.**
34
+
35
+ ---
36
+
37
+ ## The endpoint
38
+ CF AI Gateway exposes an **OpenAI-compatible** endpoint.
39
+
40
+ Base URL shape:
41
+ ```
42
+ https://gateway.ai.cloudflare.com/v1/<ACCOUNT_ID>/<GATEWAY_NAME>/workers-ai/v1
43
+ ```
44
+ then POST to `/chat/completions`.
45
+
46
+ - **Auth:** header `Authorization: Bearer <CLOUDFLARE_API_TOKEN>` — a CF API token with **Workers AI** permission.
47
+ - **Gateways:** an account can have multiple AI Gateways. Use a gateway with `authentication: false` for the simplest setup (no extra gateway key). List them:
48
+ `GET /accounts/<ACCOUNT_ID>/ai-gateway/gateways`
49
+ - **Model ids on Workers AI:** see the model-selection table below. Defaults:
50
+ - single structured-JSON call → `@cf/qwen/qwen2.5-coder-32b-instruct`
51
+ - agentic / deepagents → `@cf/qwen/qwen3-30b-a3b-fp8`
52
+
53
+ ---
54
+
55
+ ## The working approach: single structured-JSON call
56
+ - System prompt: instruct the model to **"return ONLY valid JSON, no explanation, no markdown."** Put the exact JSON shape in the user prompt.
57
+ - **Quirk:** Workers AI may return `content` as an **already-parsed JSON object (dict)**, NOT a JSON string. Your parser MUST handle both — see `_coerce_content`.
58
+ - `response_format: {"type":"json_object"}` was **unreliable/errored** in our test. Rely on prompt instructions + the coerce helper instead.
59
+ - **Always normalize/validate** model output against your enums server-side; fall back to safe defaults (`"other"` / `"neutral"`) for off-enum values. Never trust raw output to match your schema.
60
+ - Qwen handles **Vietnamese** well (we generate Vietnamese draft replies).
61
+
62
+ ---
63
+
64
+ ## Which Workers AI model for tool-calling / agentic frameworks (deepagents)
65
+ Tested live through the CF AI Gateway OpenAI-compatible endpoint for REAL tool-calling support (does the model populate `tool_calls`, and does it survive a full deepagents loop):
66
+
67
+ | Model | Price (in/out per M tokens) | Raw `tool_calls` | Full deepagents loop | Verdict |
68
+ |---|---|---|---|---|
69
+ | `@cf/qwen/qwen2.5-coder-32b-instruct` | ~0.30 / (n/a) | ❌ emits call as TEXT `<tools>{...}</tools>`, `tool_calls=null` | ❌ breaks | DO NOT use for agents (fine for single structured-JSON calls) |
70
+ | `@cf/ibm-granite/granite-4.0-h-micro` | $0.017 / $0.112 | ✅ | ❌ returns malformed tool args (string not dict) → ValidationError | cheapest but too flaky for agent loops |
71
+ | `@cf/qwen/qwen3-30b-a3b-fp8` | $0.051 / $0.335 | ✅ | ✅ **WORKS** — valid args, completes graph | ✅ **RECOMMENDED** cheapest that actually works |
72
+ | `@cf/zai-org/glm-4.7-flash` | $0.061 / $0.40 | ✅ | ✅ works | ✅ runner-up |
73
+ | `@cf/openai/gpt-oss-20b` | $0.20 / $0.30 | ✅ | ❌ 400 (sends extra array content) | avoid via gateway |
74
+ | `@cf/meta/llama-3.3-70b-instruct-fp8-fast` | $0.293 / $2.253 | ✅ (advertised) | untested | pricey |
75
+
76
+ **KEY LESSON:** "supports function calling" in the model catalog does NOT mean it works through the gateway. Always smoke-test the FULL loop. The cheapest model that genuinely works with deepagents end-to-end is **`@cf/qwen/qwen3-30b-a3b-fp8`**. Use a cheap non-agentic model (`qwen2.5-coder`) for high-volume single-call triage, and `qwen3-30b` only for the agentic steps.
77
+
78
+ ---
79
+
80
+ ## deepagents + Workers AI: the content-blocks 400 gotcha + the fix
81
+ deepagents (via langchain `ChatOpenAI`) sends the SYSTEM message `content` as an ARRAY of content blocks: `[{"type":"text","text":"..."}]`. Workers AI's OpenAI-compat endpoint ONLY accepts `content` as a plain string, so it 400s:
82
+
83
+ ```
84
+ AiError: Bad input: ... Type mismatch of '/messages/0/content', 'string' not in 'array'
85
+ ```
86
+
87
+ **FIX** — a shim that flattens array content to a string before the request hits Workers AI. Monkeypatch the OpenAI client's `create` (import once, before constructing the agent):
88
+
89
+ ```python
90
+ import openai
91
+ _orig = openai.resources.chat.completions.Completions.create
92
+ def _flatten(content):
93
+ if isinstance(content, list):
94
+ return "".join(b.get("text","") for b in content if isinstance(b, dict) and b.get("type")=="text")
95
+ return content
96
+ def _patched(self, *a, **k):
97
+ for msg in k.get("messages", []):
98
+ if "content" in msg:
99
+ msg["content"] = _flatten(msg["content"])
100
+ return _orig(self, *a, **k)
101
+ openai.resources.chat.completions.Completions.create = _patched
102
+ ```
103
+
104
+ With this shim + `qwen3-30b-a3b-fp8`, deepagents runs end-to-end on Cloudflare (tool executed, valid args, coherent Vietnamese answer).
105
+
106
+ > Note: a plain langchain `.invoke("string")` already sends string content fine — only deepagents' structured system prompt triggers the array. You only need the shim when running deepagents (or anything that builds multi-block content).
107
+
108
+ ### deepagents setup that works
109
+ ```python
110
+ from langchain_openai import ChatOpenAI
111
+ from deepagents import create_deep_agent
112
+ model = ChatOpenAI(model="@cf/qwen/qwen3-30b-a3b-fp8",
113
+ base_url=f"https://gateway.ai.cloudflare.com/v1/{ACCT}/{GATEWAY}/workers-ai/v1",
114
+ api_key=CF_API_TOKEN, temperature=0)
115
+ agent = create_deep_agent(model=model, tools=[...], system_prompt="...")
116
+ result = agent.invoke({"messages":[{"role":"user","content":"..."}]})
117
+ ```
118
+ Versions tested: **deepagents 0.6.8, langchain-openai 1.3.0.**
119
+
120
+ ### Tool-loop smoke test (before trusting a model for agents)
121
+ Run a real `agent.invoke(...)` with one trivial tool and assert: (a) the model populates `tool_calls`, (b) the args parse as a dict, (c) the graph completes. If any fail, the model is not agent-ready through the gateway regardless of the catalog claim.
122
+
123
+ ---
124
+
125
+ ## Architecture patterns that worked
126
+ - **Async after the write.** Run the LLM call in a FastAPI `BackgroundTasks` task AFTER the main DB write, so the user-facing request (e.g. ticket creation) NEVER blocks on the LLM. Create the record first with a stubbed `pending` AI block; the background task flips it to `done` or `failed`.
127
+ - **Store result as JSONB** (`ai` column) on the row:
128
+ `{category, sentiment, draft_reply, faq_used, status: pending|done|failed, generated_at}`
129
+ - **Failure handling.** Wrap the whole triage in `try/except`; on ANY exception set `status="failed"` and **never raise to the caller**. The agent must never crash the main flow. Bad token → 401 → `status=failed`, record still intact.
130
+ - **Grounding without a vector DB (RAG-lite).** v1: pass a small FIXED set of hand-curated FAQ snippets in the prompt; have the model return which ids it used (`faq_used`). v2 (when the board can upload docs): store `@cf/baai/bge-m3` embeddings as a **JSONB float array** next to each snippet in Postgres and compute **cosine in-app** — no vector DB needed for a few hundred snippets. See the RAG-lite section below.
131
+ - **Safety: draft only.** The agent DRAFTS; a human approves before anything is sent to an end user. Never auto-send LLM output.
132
+
133
+ ---
134
+
135
+ ## RAG-lite: bge-m3 embeddings in Postgres + in-app cosine (no vector DB)
136
+ For a small corpus (building docs, FAQ, a few hundred snippets) you do NOT need a vector DB. Store embeddings as JSONB next to the text and compute cosine in Python. Fall back to the hardcoded FAQ when no docs are uploaded.
137
+
138
+ **Embeddings endpoint (same gateway, `/embeddings`):**
139
+ ```python
140
+ import httpx
141
+ EMBED_MODEL = "@cf/baai/bge-m3" # multilingual — handles Vietnamese
142
+ async def embed(texts: list[str]) -> list[list[float]]:
143
+ url = settings.cf_ai_gateway_url.rstrip("/") + "/embeddings"
144
+ async with httpx.AsyncClient(timeout=settings.agent_timeout_seconds) as client:
145
+ resp = await client.post(url,
146
+ headers={"Authorization": f"Bearer {settings.cf_ai_token}"},
147
+ json={"model": EMBED_MODEL, "input": texts})
148
+ resp.raise_for_status()
149
+ data = resp.json()
150
+ return [row["embedding"] for row in data["data"]]
151
+ ```
152
+
153
+ **Storage:** SQLAlchemy `embedding: Mapped[list] = mapped_column(JSONB, default=list)`.
154
+
155
+ **In-app cosine top-K:**
156
+ ```python
157
+ import math
158
+ def _cosine(a, b):
159
+ if not a or not b: return 0.0
160
+ dot = sum(x*y for x, y in zip(a, b))
161
+ na = math.sqrt(sum(x*x for x in a)); nb = math.sqrt(sum(y*y for y in b))
162
+ return dot/(na*nb) if na and nb else 0.0
163
+
164
+ TOP_K, MIN_SIM = 3, 0.30 # below MIN_SIM a snippet is too unrelated to inject
165
+ async def build_grounding(session, query_text):
166
+ docs = (await session.execute(select(DocSnippet))).scalars().all()
167
+ if not docs:
168
+ return faq_block(), [], FAQ_IDS # fall back to hardcoded FAQ
169
+ qv = await embed_one(query_text)
170
+ scored = sorted(((_cosine(qv, d.embedding or []), d) for d in docs),
171
+ key=lambda t: t[0], reverse=True)
172
+ top = [(s, d) for s, d in scored[:TOP_K] if s >= MIN_SIM]
173
+ if not top:
174
+ return faq_block(), [], FAQ_IDS | {d.id for d in docs} # nothing relevant: FAQ not noise
175
+ block = "\n".join(f"- [{d.id}] {d.title}: {d.content}" for _, d in top)
176
+ return block, [d.id for _, d in top], {d.id for d in docs} | FAQ_IDS
177
+ ```
178
+
179
+ Feed `block` into the user prompt as grounding; validate the model's `faq_used` against the returned valid-id set. When the corpus grows past a few thousand rows, swap the in-app cosine for `pgvector` — same shape, just move the math to SQL.
180
+
181
+ ---
182
+
183
+ ## Code skeleton (httpx async)
184
+
185
+ ```python
186
+ # config.py
187
+ class Settings:
188
+ cf_ai_gateway_url: str # CF_AI_GATEWAY_URL = https://gateway.ai.cloudflare.com/v1/<acct>/<gw>/workers-ai/v1
189
+ cf_ai_token: str # CF_AI_TOKEN = Cloudflare API token (Workers AI perm)
190
+ agent_model: str # AGENT_MODEL = @cf/qwen/qwen2.5-coder-32b-instruct
191
+ agent_timeout_seconds: float = 30.0
192
+
193
+ # agent.py
194
+ import json
195
+ import httpx
196
+ from datetime import datetime, timezone
197
+
198
+ CATEGORIES = {"repair", "billing", "complaint", "request", "other"}
199
+ SENTIMENTS = {"angry", "neutral", "happy"}
200
+
201
+ async def _call_qwen(system: str, user: str, max_tokens: int = 800) -> object:
202
+ url = f"{settings.cf_ai_gateway_url}/chat/completions"
203
+ payload = {
204
+ "model": settings.agent_model,
205
+ "messages": [
206
+ {"role": "system", "content": system},
207
+ {"role": "user", "content": user},
208
+ ],
209
+ "max_tokens": max_tokens,
210
+ }
211
+ headers = {"Authorization": f"Bearer {settings.cf_ai_token}"}
212
+ async with httpx.AsyncClient(timeout=settings.agent_timeout_seconds) as client:
213
+ r = await client.post(url, json=payload, headers=headers)
214
+ r.raise_for_status()
215
+ content = r.json()["choices"][0]["message"]["content"]
216
+ return _coerce_content(content)
217
+
218
+ def _coerce_content(content) -> dict:
219
+ # Workers AI may return content as a dict OR a string.
220
+ if isinstance(content, dict):
221
+ return content
222
+ s = content.strip()
223
+ if s.startswith("```"):
224
+ s = s.strip("`")
225
+ if s.lower().startswith("json"):
226
+ s = s[4:]
227
+ return json.loads(s.strip())
228
+
229
+ def _normalize(raw: dict) -> dict:
230
+ cat = raw.get("category")
231
+ sent = raw.get("sentiment")
232
+ return {
233
+ "category": cat if cat in CATEGORIES else "other",
234
+ "sentiment": sent if sent in SENTIMENTS else "neutral",
235
+ "draft_reply": str(raw.get("draft_reply", "")),
236
+ "faq_used": [x for x in raw.get("faq_used", []) if isinstance(x, str)],
237
+ }
238
+
239
+ FAQ = [
240
+ {"id": "faq-water", "topic": "Nước", "text": "Sự cố nước báo BQL tầng 1..."},
241
+ # ... small hand-curated set
242
+ ]
243
+
244
+ SYSTEM = (
245
+ "Bạn là trợ lý phân loại ticket chung cư. "
246
+ "Chỉ trả về JSON hợp lệ, không giải thích, không markdown."
247
+ )
248
+
249
+ def _user_prompt(ticket_text: str) -> str:
250
+ faq = "\n".join(f'{f["id"]}: {f["topic"]} - {f["text"]}' for f in FAQ)
251
+ return (
252
+ f"FAQ:\n{faq}\n\n"
253
+ f"Ticket: {ticket_text}\n\n"
254
+ 'Trả về JSON theo shape: '
255
+ '{"category":"repair|billing|complaint|request|other",'
256
+ '"sentiment":"angry|neutral|happy",'
257
+ '"draft_reply":"<tiếng Việt>",'
258
+ '"faq_used":["<faq id>"]}'
259
+ )
260
+
261
+ async def triage_ticket(ticket_id: int):
262
+ row = await load_ticket(ticket_id) # your DB load
263
+ try:
264
+ raw = await _call_qwen(SYSTEM, _user_prompt(row.body))
265
+ result = _normalize(raw)
266
+ row.ai = {**result, "status": "done",
267
+ "generated_at": datetime.now(timezone.utc).isoformat()}
268
+ except Exception:
269
+ row.ai = {"category": None, "sentiment": None, "draft_reply": None,
270
+ "faq_used": [], "status": "failed",
271
+ "generated_at": datetime.now(timezone.utc).isoformat()}
272
+ await commit(row) # never re-raise
273
+
274
+ # routes.py
275
+ @router.post("/tickets")
276
+ async def create_ticket(payload: TicketIn, background_tasks: BackgroundTasks):
277
+ ticket = await db_create(payload, ai={"status": "pending"}) # stub first
278
+ background_tasks.add_task(triage_ticket, ticket.id) # async after write
279
+ return ticket
280
+ ```
281
+
282
+ ---
283
+
284
+ ## Deployment notes
285
+ - **Env vars:** `CF_AI_GATEWAY_URL`, `CF_AI_TOKEN` (the Cloudflare API token), `AGENT_MODEL`.
286
+ - **Confirm routing via gateway logs:**
287
+ ```
288
+ GET /accounts/<ACCOUNT_ID>/ai-gateway/gateways/<GW>/logs?per_page=5&order_by=created_at&order_by_direction=desc
289
+ ```
290
+ Shows `model`, `success`, `status_code`, `tokens_out` per request. Both 200 successes AND 401 failures appear here — good for confirming the request actually went through the gateway.
291
+
292
+ ---
293
+
294
+ ## Smoke tests (run these first)
295
+
296
+ ```bash
297
+ BASE="https://gateway.ai.cloudflare.com/v1/<ACCOUNT_ID>/<GATEWAY_NAME>/workers-ai/v1"
298
+ TOKEN="<CLOUDFLARE_API_TOKEN>"
299
+ ```
300
+
301
+ **1. Plain chat — confirms model + gateway + auth:**
302
+ ```bash
303
+ curl -s "$BASE/chat/completions" \
304
+ -H "Authorization: Bearer $TOKEN" \
305
+ -H "Content-Type: application/json" \
306
+ -d '{"model":"@cf/qwen/qwen2.5-coder-32b-instruct",
307
+ "messages":[{"role":"user","content":"Say hello in Vietnamese."}],
308
+ "max_tokens":50}'
309
+ ```
310
+
311
+ **2. Tool-calling — confirms THE GOTCHA. Expect `tool_calls: null` + the function call as TEXT in `content`:**
312
+ ```bash
313
+ curl -s "$BASE/chat/completions" \
314
+ -H "Authorization: Bearer $TOKEN" \
315
+ -H "Content-Type: application/json" \
316
+ -d '{"model":"@cf/qwen/qwen2.5-coder-32b-instruct",
317
+ "messages":[{"role":"user","content":"Triage: the elevator is broken and I am furious."}],
318
+ "tools":[{"type":"function","function":{
319
+ "name":"triage",
320
+ "parameters":{"type":"object","properties":{
321
+ "category":{"type":"string"},"sentiment":{"type":"string"}},
322
+ "required":["category","sentiment"]}}}],
323
+ "tool_choice":"auto",
324
+ "max_tokens":200}'
325
+ ```
326
+ Look at `choices[0].message`: `tool_calls` will be `null`, and `content` will hold
327
+ `<function-name>triage</function-name><args-json-object>{...}</args-json-object>`.
328
+ → Do NOT build a tool-calling agent on this. Use approach #3.
329
+
330
+ **3. JSON output — the approach that works:**
331
+ ```bash
332
+ curl -s "$BASE/chat/completions" \
333
+ -H "Authorization: Bearer $TOKEN" \
334
+ -H "Content-Type: application/json" \
335
+ -d '{"model":"@cf/qwen/qwen2.5-coder-32b-instruct",
336
+ "messages":[
337
+ {"role":"system","content":"Return ONLY valid JSON, no explanation, no markdown."},
338
+ {"role":"user","content":"Ticket: elevator broken, I am furious. Return {\"category\":\"...\",\"sentiment\":\"...\"}"}
339
+ ],
340
+ "max_tokens":200}'
341
+ ```
342
+ Expect a clean JSON object in `content` (may already be parsed as a dict — handle both).
343
+
344
+ ---
345
+
346
+ *Provenance: ab-tickets project, updated 2026-06 with deepagents model testing.*
@@ -0,0 +1,32 @@
1
+ # Playbook: Docker layer cache serving stale backend builds on deploy
2
+
3
+ ## When to use this
4
+ Any Dockerized backend with auto-deploy where a newly added route/endpoint returns 404
5
+ on the deployed URL even though the code is merged and the deploy reported success.
6
+
7
+ ## ⚠️ THE GOTCHA
8
+ The deploy "succeeds" but runs a STALE image: the layer cache reuses the old source
9
+ layer, so your new endpoint never registers. Symptom: merged + deployed + green, yet the
10
+ live `/docs` (or a curl) shows the route MISSING. Each occurrence reads like a code bug
11
+ and costs a false debugging detour.
12
+
13
+ **Diagnosis in one step:** check the live `/openapi.json` (or `/docs`) for the new route
14
+ BEFORE debugging code. Route absent from the spec = stale image, not your code.
15
+ (This is the swagger-lands-with-the-API rule earning its keep.)
16
+
17
+ ## Fixes (pick one, in order of preference)
18
+ 1. **Structure the Dockerfile so source COPY busts the cache**: dependencies layer first
19
+ (`COPY requirements.txt` + install), then `COPY . .` for source — source changes then
20
+ always rebuild the final layer while deps stay cached (fast AND fresh).
21
+ 2. **Dev phase: bind-mount the source** into the container instead of baking it, so
22
+ deploys don't depend on image rebuilds at all.
23
+ 3. **Last resort: force `--no-cache`** on the rebuild (slow; treats the symptom).
24
+
25
+ ## Smoke test
26
+ After any deploy that adds a route:
27
+ ```bash
28
+ curl -s https://<live-url>/openapi.json | grep '<new-route-path>' || echo "STALE IMAGE"
29
+ ```
30
+
31
+ ---
32
+ *Provenance: ab-tickets, 2026-06 — cost ~4 false detours before the pattern was named.*
@@ -0,0 +1,192 @@
1
+ # RAG-lite — grounded LLM answers without a vector DB (Postgres + Cloudflare embeddings)
2
+
3
+ How to add Retrieval-Augmented Generation to a backend feature **without** Vectorize / pgvector
4
+ / chunking pipelines / a doc-ingestion service. For a single tenant's handful-to-hundreds of
5
+ documents this is 80% of the value at ~5% of the infra. Verified in the ab-tickets project.
6
+
7
+ Pairs with: `cloudflare-ai-gateway-qwen.md` (the LLM-call side).
8
+
9
+ ---
10
+
11
+ ## Decision rule — do you even need a vector DB?
12
+
13
+ | Situation | Use |
14
+ |---|---|
15
+ | One tenant, ≤ a few hundred short docs/snippets, retrieve top-3 to ground one answer | **RAG-lite (this playbook)** — embeddings in Postgres JSONB, cosine in app |
16
+ | Thousands+ of docs, long documents needing chunking, multi-tenant at scale, sub-100ms retrieval | Real vector DB (Cloudflare Vectorize / pgvector / a managed RAG) |
17
+
18
+ Don't reach for a vector DB by reflex. Computing cosine over a few hundred 1024-float vectors in
19
+ Python is sub-millisecond. The vector DB earns its keep at scale, not at "ground this reply in
20
+ the building's rules."
21
+
22
+ ---
23
+
24
+ ## The shape
25
+
26
+ ```
27
+ doc text ──embed(bge-m3 via CF gateway)──> 1024-float vector ──store──> Postgres JSONB column
28
+ query text ──embed──> qvec ──cosine vs every stored vec (in app)──> top-K ──inject into prompt
29
+ ```
30
+
31
+ Two ways to consume the retrieved snippets:
32
+ 1. **RAG-lite grounding (single call)** — fetch top-K, paste into ONE structured LLM call. Cheap,
33
+ deterministic, no agent. Best for classify/draft/answer-this-one-thing. (C-011)
34
+ 2. **RAG as an agent tool** — expose `search_docs(query)` as a tool a deepagents agent calls when
35
+ *it* decides it needs policy context. Multi-step. Best for recap/investigate/chat. (C-013)
36
+
37
+ ---
38
+
39
+ ## Embeddings via Cloudflare AI Gateway (same seam as the chat model)
40
+
41
+ Model: `@cf/baai/bge-m3` — 1024-dim, multilingual (handles Vietnamese well), batch input.
42
+ OpenAI-compatible `/embeddings` endpoint on the same gateway base URL as chat.
43
+
44
+ ```python
45
+ import httpx
46
+ EMBED_MODEL = "@cf/baai/bge-m3"
47
+
48
+ async def embed(texts: list[str]) -> list[list[float]]:
49
+ url = settings.cf_ai_gateway_url.rstrip("/") + "/embeddings"
50
+ async with httpx.AsyncClient(timeout=45) as client:
51
+ r = await client.post(url,
52
+ headers={"Authorization": f"Bearer {settings.cf_ai_token}"},
53
+ json={"model": EMBED_MODEL, "input": texts})
54
+ r.raise_for_status()
55
+ return [row["embedding"] for row in r.json()["data"]]
56
+ ```
57
+
58
+ Smoke test (confirm dim + that it works through YOUR gateway):
59
+ ```bash
60
+ curl "$BASE/embeddings" -H "Authorization: Bearer $CF_TOKEN" \
61
+ -d '{"model":"@cf/baai/bge-m3","input":["test một","test hai"]}' \
62
+ | python3 -c "import sys,json;d=json.load(sys.stdin);print('dim',len(d['data'][0]['embedding']))"
63
+ # -> dim 1024
64
+ ```
65
+
66
+ ---
67
+
68
+ ## Storage — Postgres JSONB, no extension
69
+
70
+ ```python
71
+ class DocSnippet(Base):
72
+ __tablename__ = "doc_snippets"
73
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid)
74
+ title: Mapped[str] = mapped_column(String(300))
75
+ content: Mapped[str] = mapped_column(Text)
76
+ embedding: Mapped[list] = mapped_column(JSONB, default=list) # the 1024-float vector
77
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
78
+ ```
79
+
80
+ Embed on insert (so retrieval is read-only):
81
+ ```python
82
+ @router.post("/docs")
83
+ async def add_doc(payload: DocCreate, session = Depends(get_session)):
84
+ vector = (await embed([f"{payload.title}\n{payload.content}"]))[0] # embed title+content
85
+ doc = DocSnippet(title=payload.title, content=payload.content, embedding=vector)
86
+ session.add(doc); await session.commit(); await session.refresh(doc)
87
+ return doc # NOTE: response schema must NOT include `embedding` — don't leak the vector
88
+ ```
89
+
90
+ GOTCHA: define a `DocOut` schema WITHOUT the `embedding` field, or the API leaks a 1024-number
91
+ array on every list call. Embed `title + content` together so a query matches on either.
92
+
93
+ ---
94
+
95
+ ## Retrieval — cosine top-K in app, with a fallback
96
+
97
+ ```python
98
+ import math
99
+ TOP_K = 3
100
+ MIN_SIM = 0.30 # below this, a snippet is too unrelated to inject — skip it
101
+
102
+ def _cosine(a, b):
103
+ if not a or not b: return 0.0
104
+ dot = sum(x*y for x, y in zip(a, b))
105
+ na = math.sqrt(sum(x*x for x in a)); nb = math.sqrt(sum(y*y for y in b))
106
+ return dot/(na*nb) if na and nb else 0.0
107
+
108
+ async def build_grounding(session, query_text):
109
+ docs = (await session.execute(select(DocSnippet))).scalars().all()
110
+ if not docs:
111
+ return faq_block(), [], FAQ_IDS # fallback A: no docs yet -> static FAQ
112
+ qv = (await embed([query_text]))[0]
113
+ scored = sorted(((_cosine(qv, d.embedding or []), d) for d in docs),
114
+ key=lambda t: t[0], reverse=True)
115
+ top = [(s, d) for s, d in scored[:TOP_K] if s >= MIN_SIM]
116
+ if not top:
117
+ return faq_block(), [], FAQ_IDS # fallback B: nothing relevant -> FAQ
118
+ block = "\n".join(f"- [{d.id}] {d.title}: {d.content}" for _, d in top)
119
+ return block, [d.id for _, d in top], {d.id for d in docs} | FAQ_IDS
120
+ ```
121
+
122
+ Three design points that matter:
123
+ - **MIN_SIM threshold.** Without it, you always inject the 3 "least bad" docs even when none are
124
+ relevant — the model then hallucinates a connection. Below threshold, fall back to a generic
125
+ FAQ (or nothing) instead of forcing irrelevant context.
126
+ - **Graceful fallback chain.** No docs uploaded → static FAQ. Docs exist but none match → FAQ.
127
+ Only inject docs when genuinely similar. The feature must work from day-zero (empty corpus).
128
+ - **Return the matched ids.** Ask the LLM to echo which snippet ids it used (`faq_used`), then
129
+ validate that echo against the real id set server-side. Gives you "grounded in: [doc X]"
130
+ provenance for free, and catches the model inventing citations.
131
+
132
+ ---
133
+
134
+ ## Pattern 1 — RAG-lite grounding in a single structured call (C-011)
135
+
136
+ Inject the retrieved block into the SAME single-call prompt (see the gateway playbook for the
137
+ single-call + `_coerce_content` + enum-normalize approach). System prompt: *"Chỉ dùng thông tin
138
+ trong tài liệu được cung cấp; nếu không chắc, đề nghị chờ xác minh."* (Only use the provided docs;
139
+ if unsure, ask to wait for verification.)
140
+
141
+ Real result: pasted a building rule "không nuôi chó trên 20kg" → resident asks about a 25kg dog →
142
+ the grounded reply correctly said it exceeds the 20kg limit. That rule existed ONLY in the pasted
143
+ doc, nowhere in code. Same query with no matching doc fell back to the generic FAQ, no crash.
144
+
145
+ ---
146
+
147
+ ## Pattern 2 — RAG as a deepagents tool (C-013)
148
+
149
+ When the feature is agentic (the agent decides what it needs), expose retrieval as a tool instead
150
+ of pre-injecting. The agent calls `search_docs(query)` with its OWN query, possibly multiple times.
151
+
152
+ ```python
153
+ def search_docs(query: str) -> str:
154
+ """Tìm trong tài liệu/nội quy các đoạn liên quan tới truy vấn (RAG)."""
155
+ # deepagents tools run SYNC -> use a short-lived sync psycopg connection + sync embed,
156
+ # separate from the app's async engine. (asyncpg URL -> postgresql:// for psycopg.)
157
+ docs = _fetch_docs_sync()
158
+ qv = _embed_sync(query)
159
+ top = sorted(((_cosine(qv, emb), t, c) for t, c, emb in docs), reverse=True)[:3]
160
+ return "\n".join(f"[{t}] {c}" for s, t, c in top if s >= 0.25) or "Không tìm thấy."
161
+ ```
162
+
163
+ Observed live: the recap agent called `list_open_tickets()`, saw fire + water complaints, then
164
+ **on its own** called `search_docs('fire safety')` AND `search_docs('water supply')` — two distinct
165
+ retrievals it chose based on what it found. That autonomy is the whole point of pattern 2; pattern 1
166
+ can't do it because the query is fixed before the call.
167
+
168
+ Sync-tool gotchas:
169
+ - deepagents tools are sync functions. Don't await inside them. Use `psycopg` (sync) + sync `httpx`
170
+ for the embed, with a DSN derived from the async URL (`postgresql+asyncpg://` → `postgresql://`).
171
+ - Run the whole agent in `run_in_threadpool(...)` from the async endpoint so it doesn't block the
172
+ event loop.
173
+ - JSONB vectors may come back as a Python list (SQLAlchemy) or a JSON string (raw psycopg) —
174
+ `json.loads(emb) if isinstance(emb, str) else emb`.
175
+
176
+ ---
177
+
178
+ ## When to graduate to a real vector DB
179
+
180
+ Move off RAG-lite when any of these hits:
181
+ - Linear cosine scan gets slow (rule of thumb: > ~1–2k snippets per query).
182
+ - Documents are long and need **chunking** (RAG-lite assumes each row is one already-small snippet).
183
+ - You need filtered/metadata search, hybrid keyword+vector, or multi-tenant isolation at scale.
184
+ Then: Cloudflare Vectorize (stays on CF), or pgvector (stays in Postgres, adds an index). The
185
+ embed step (bge-m3 via the gateway) and the prompt-injection step are unchanged — only storage +
186
+ the top-K query move.
187
+
188
+ ---
189
+
190
+ Provenance: ab-tickets project (Vietnamese apartment-ticket app), 2026-06. RAG-lite = card C-011;
191
+ RAG-as-agent-tool = card C-013. Stack: FastAPI + Postgres + Cloudflare Workers AI (bge-m3) via AI
192
+ Gateway. No Vectorize, no pgvector, no chunking.
@@ -0,0 +1,57 @@
1
+ # Playbook: JSONL telemetry + tests on Windows Git Bash (shell writer / Python reader)
2
+
3
+ ## When to use this
4
+ Any card that writes a JSONL/log file from POSIX shell (`flow.sh`) and reads or rolls it up
5
+ from Python (`flow_harness.py`), OR any test that seeds a home-relative / temp-relative path
6
+ and must run on Git Bash for Windows as well as macOS/Linux. The whole usage-log subsystem.
7
+
8
+ ## ⚠️ THE GOTCHA (this breaks first)
9
+ **Bash `$HOME` and Python `os.path.expanduser("~")` resolve to DIFFERENT directories on
10
+ Windows.** Git Bash honors `$HOME`; Python on Windows uses `USERPROFILE` (then `HOMEDRIVE`+
11
+ `HOMEPATH`). So a test that does `export HOME="$SB/home"` and writes the device-global log
12
+ with the shell, then reads it with `python ... usage --global`, silently reads the REAL
13
+ `C:\Users\<you>\.claude\flow\usage.jsonl` instead of the sandbox — the test passes against
14
+ production data or "finds nothing," and you chase a ghost.
15
+
16
+ **Fix in tests:** set BOTH for any home-relative Python path:
17
+ ```sh
18
+ export HOME="$SB/home"; export USERPROFILE="$SB/home"
19
+ ```
20
+
21
+ ## Second gotcha — temp-dir path forms don't match
22
+ `$ROOT`/`$PWD` in Git Bash are POSIX (`/c/Users/.../Temp/proj`) but `$TEMP`/`$TMP` are
23
+ Windows native (`C:\Users\...\Temp`). A naive `case "$ROOT" in "$TEMP"/*)` NEVER matches on
24
+ Windows. Normalize both sides before comparing: backslash→slash, lowercase (Windows is
25
+ case-insensitive), and `C:/`→`/c/`:
26
+ ```sh
27
+ _norm_path() { printf '%s' "${1:-}" | tr 'A-Z\\' 'a-z/' | sed -E 's#^([a-z]):/#/\1/#'; }
28
+ ```
29
+ Belt-and-suspenders: also match an mktemp-style basename (`tmp.*`) and a read-time SQL
30
+ `project LIKE 'tmp.%'` — the name convention catches what path math misses.
31
+
32
+ ## Third gotcha — there is NO atomic append for regular files
33
+ POSIX guarantees atomic writes only for **pipes** ≤ `PIPE_BUF`, NOT regular files. "Keep the
34
+ line small so the append is atomic" is folklore for files. And **`flock` is absent in Git
35
+ Bash for Windows** (verified) — don't reach for it. For a single-session-per-project tool the
36
+ de-facto small-write append is fine; if you ever need real device-wide concurrency, shard the
37
+ sink per-process/day and merge at read time (no lock needed). Keep JSON lines small and
38
+ strip control chars (`tr -d '\000-\037'`) so one bad field can't corrupt a line the reader
39
+ then skips.
40
+
41
+ ## Smoke tests (run before trusting telemetry on Windows)
42
+ ```sh
43
+ # 1) shell writer and python reader agree on the global path
44
+ export HOME="$PWD/sb/home"; export USERPROFILE="$PWD/sb/home"; mkdir -p "$PWD/sb/home/.claude/flow"
45
+ python -c "import os;print(os.path.join(os.path.expanduser('~'),'.claude','flow','usage.jsonl'))"
46
+ # -> must print a path UNDER $PWD/sb/home, not C:\Users\<you>\...
47
+
48
+ # 2) a project under $TEMP is detected ephemeral (path branch, non-tmp.* name)
49
+ P="${TEMP}/realbuild_$$"; mkdir -p "$P"
50
+ FLOW_PROJECT_ROOT="$P" bash runner/flow.sh status >/dev/null 2>&1
51
+ grep -o '"ephemeral":[01]' "$P/.flow/events.jsonl" | tail -1 # -> "ephemeral":1
52
+ ```
53
+
54
+ ## Provenance
55
+ flow-skill v0.11.0 usage-log telemetry correctness (2026-06-20). Gotchas 1+2 cost real test
56
+ debugging in cards C-001/C-004; gotcha 3 corrected a wrong code comment about PIPE_BUF and
57
+ killed the flock approach after verifying flock is missing on this Git Bash.