@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.
- package/CHANGELOG.md +60 -0
- package/LICENSE +21 -0
- package/README.md +152 -0
- package/README_VN.md +134 -0
- package/SECURITY.md +69 -0
- package/bin/cli.mjs +382 -0
- package/package.json +52 -0
- package/skills/flow/SKILL.md +234 -0
- package/skills/flow/_templates/00-idea.md +16 -0
- package/skills/flow/_templates/00-inspect.md +42 -0
- package/skills/flow/_templates/01-research.md +59 -0
- package/skills/flow/_templates/02-scope.md +71 -0
- package/skills/flow/_templates/03-prd.md +57 -0
- package/skills/flow/_templates/04-adr.md +21 -0
- package/skills/flow/_templates/05-contract.md +59 -0
- package/skills/flow/_templates/card.md +26 -0
- package/skills/flow/_templates/constitution.md +22 -0
- package/skills/flow/eval/fixtures/f01a/flow/01-research.md +77 -0
- package/skills/flow/eval/fixtures/f01b/flow/01-research.md +75 -0
- package/skills/flow/eval/fixtures/f02a/flow/02-scope.md +56 -0
- package/skills/flow/eval/fixtures/f02b/flow/02-scope.md +65 -0
- package/skills/flow/eval/fixtures/fcda/.flow/.gitkeep +0 -0
- package/skills/flow/eval/fixtures/fcda/cards/C-001.md +46 -0
- package/skills/flow/eval/fixtures/fcdb/.flow/.gitkeep +0 -0
- package/skills/flow/eval/fixtures/fcdb/cards/C-001.md +35 -0
- package/skills/flow/eval/manifest.tsv +7 -0
- package/skills/flow/harness/README.md +117 -0
- package/skills/flow/harness/_db.py +228 -0
- package/skills/flow/harness/_domain.py +161 -0
- package/skills/flow/harness/_presence.py +109 -0
- package/skills/flow/harness/flow_harness.py +1103 -0
- package/skills/flow/harness/repo_map.py +93 -0
- package/skills/flow/harness/schema/001-init.sql +129 -0
- package/skills/flow/harness/schema/002-story-verify.sql +9 -0
- package/skills/flow/harness/schema/003-tool-registry.sql +15 -0
- package/skills/flow/harness/schema/004-intervention.sql +15 -0
- package/skills/flow/harness/schema/005-tool-extensions.sql +20 -0
- package/skills/flow/harness/schema/009-accessed-count.sql +11 -0
- package/skills/flow/harness/schema/010-usage-event.sql +42 -0
- package/skills/flow/harness/schema/011-usage-gate-reason.sql +8 -0
- package/skills/flow/harness/schema/012-usage-ephemeral.sql +10 -0
- package/skills/flow/law/CLAUDE.md +140 -0
- package/skills/flow/law/DESIGN.md +141 -0
- package/skills/flow/law/RETRO.md +13 -0
- package/skills/flow/playbooks/README.md +28 -0
- package/skills/flow/playbooks/cloudflare-ai-gateway-qwen.md +346 -0
- package/skills/flow/playbooks/docker-deploy-stale-cache.md +32 -0
- package/skills/flow/playbooks/rag-lite-postgres-cloudflare.md +192 -0
- package/skills/flow/playbooks/windows-gitbash-jsonl-telemetry.md +57 -0
- package/skills/flow/references/adversarial-review.md +197 -0
- package/skills/flow/references/agent-detection.md +88 -0
- package/skills/flow/references/agent-stage-mapping.md +114 -0
- package/skills/flow/references/antigravity-integration.md +92 -0
- package/skills/flow/references/auto-run.md +81 -0
- package/skills/flow/references/claudekit-skills.md +128 -0
- package/skills/flow/references/codex-integration.md +104 -0
- package/skills/flow/references/command-dispatch.md +54 -0
- package/skills/flow/references/debt-and-halts.md +41 -0
- package/skills/flow/references/design-review-checklist.md +42 -0
- package/skills/flow/references/gate-eval.md +186 -0
- package/skills/flow/references/gate-rules.md +171 -0
- package/skills/flow/references/ground-truth-gates.md +49 -0
- package/skills/flow/references/loop-harness-2026-principles.md +52 -0
- package/skills/flow/references/mode-work.md +37 -0
- package/skills/flow/references/project-types.md +42 -0
- package/skills/flow/references/stage-state-machine.md +47 -0
- package/skills/flow/references/ui-patterns-tcr.md +49 -0
- package/skills/flow/runner/flow.cmd +18 -0
- package/skills/flow/runner/flow.sh +3297 -0
- package/skills-manifest.json +70 -0
- package/src/constants.mjs +65 -0
- package/src/detect.mjs +36 -0
- package/src/help.mjs +42 -0
- package/src/installer.mjs +231 -0
- package/src/prompt.mjs +60 -0
- 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.
|