Skip to content

Commit 6bd48b7

Browse files
committed
docs(examples): slim to pure runnable samples, drop test harness
移除断言层(TurnResult/wait_reply/turn/ProjectMemory),保留运行时;场景改纯演示 print、无 verify;删 conftest 与两个 test_live。 Task: 1789906941
1 parent 55864d8 commit 6bd48b7

14 files changed

Lines changed: 211 additions & 337 deletions

File tree

‎examples/common/live.py‎

Lines changed: 0 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import argparse
44
import json
55
import os
6-
import random
76
import re
87
import sys
98
import time
@@ -171,89 +170,6 @@ def cleanup(self) -> None:
171170
self.output("cleanup", "completed")
172171

173172

174-
@dataclass
175-
class TurnResult:
176-
text: str = ""
177-
last_id: str = ""
178-
tool_used: bool = False
179-
complete: bool = False
180-
181-
def observe(self, event: Any) -> None:
182-
if hasattr(event, "to_dict"):
183-
event = event.to_dict(mode="json")
184-
kind = event.get("type")
185-
if event.get("id"):
186-
self.last_id = event["id"]
187-
if kind in ("session.error", "session.status_terminated"):
188-
raise AssertionError(f"Execution failed: {kind}, event_id={self.last_id}")
189-
if kind in ("agent.tool_use", "agent.mcp_tool_use"):
190-
self.tool_used = True
191-
elif kind == "agent.message":
192-
# Only the latest completed assistant message can satisfy assertions.
193-
self.text = "\n".join(
194-
block.get("text", "") for block in event.get("content", []) if block.get("type") == "text"
195-
)
196-
elif kind == "session.status_idle":
197-
reason = event.get("stop_reason")
198-
reason = reason.get("type") if isinstance(reason, dict) else reason
199-
if reason not in (None, "", "end_turn", "stop_sequence"):
200-
raise AssertionError(f"Execution stopped early: {reason}, event_id={self.last_id}")
201-
self.complete = bool(self.text)
202-
203-
def verify(self, expected: list[str], require_tool: bool = False) -> None:
204-
if not self.complete:
205-
raise AssertionError(f"No idle state after assistant output; last_event_id={self.last_id}")
206-
if not all(value in self.text for value in expected):
207-
raise AssertionError(f"Assistant output is missing expected values; last_event_id={self.last_id}")
208-
if require_tool and not self.tool_used:
209-
raise AssertionError(f"No actual tool execution; last_event_id={self.last_id}")
210-
211-
212-
def wait_reply(events: Any, run: Run, session_id: str, after: str = "") -> TurnResult:
213-
result = TurnResult(last_id=after)
214-
while not result.complete:
215-
run.remaining()
216-
page = events.list(
217-
session_id,
218-
order="asc",
219-
limit=100,
220-
extra_query={"after_id": result.last_id or None, "include_tool_calls": True},
221-
timeout=min(run.remaining(), 30),
222-
)
223-
for index, event in enumerate(page):
224-
if index >= 2000:
225-
raise AssertionError("Event polling exceeded 2000 events")
226-
result.observe(event)
227-
if result.complete:
228-
break
229-
if not result.complete:
230-
run.pause()
231-
run.output("assistant", result.text)
232-
return result
233-
234-
235-
def turn(
236-
events: Any,
237-
run: Run,
238-
session_id: str,
239-
prompt: str,
240-
expected: list[str],
241-
*,
242-
require_tool: bool = False,
243-
) -> TurnResult:
244-
run.output("user", prompt)
245-
result = events.send(
246-
session_id,
247-
events=[{"type": "user.message", "content": [{"type": "text", "text": prompt}]}],
248-
extra_headers={"Idempotency-Key": name("event")},
249-
)
250-
if len(result.data) != 1 or not result.data[0].id:
251-
raise AssertionError("Send must return exactly one user event ID")
252-
reply = wait_reply(events, run, session_id, result.data[0].id)
253-
reply.verify(expected, require_tool)
254-
return reply
255-
256-
257173
def choose_model(models: Any, requested: str) -> str:
258174
enabled = [model.id for model in models.data if model.is_enabled and model.id]
259175
if requested:
@@ -265,29 +181,6 @@ def choose_model(models: Any, requested: str) -> str:
265181
return "ultimate" if "ultimate" in enabled else sorted(enabled)[0]
266182

267183

268-
@dataclass
269-
class ProjectMemory:
270-
project: str = field(default_factory=lambda: "青禾订单-" + marker()[:6])
271-
release_time: str = field(default_factory=lambda: f"{random.randrange(20, 24):02}:{random.randrange(60):02}")
272-
contact: str = field(default_factory=lambda: random.choice(["林岚", "陈朔", "叶澄", "苏棠"]))
273-
rollback_version: str = field(
274-
default_factory=lambda: f"v2.{random.randrange(100, 1000)}.{random.randrange(100, 1000)}"
275-
)
276-
path = "projects/release-conventions.md"
277-
278-
def content(self) -> str:
279-
return f"---\nname: release-conventions\ndescription: {self.project} 的项目发布约定\nmetadata:\n type: project\n---\n\n# {self.project}\n\n- 北京时间 {self.release_time} 开始发布。\n- 发布异常时联系值班负责人{self.contact}。\n- 回滚使用已验证的稳定版本 {self.rollback_version}。\n\nWhy: 在值班窗口发布,并使用验证过的版本恢复服务。\nHow to apply: 为这个项目拟定发布计划时遵循以上约定。\n"
280-
281-
def index(self) -> str:
282-
return f"- [{self.project} 发布约定]({self.path}) — 发布窗口、异常联系人与回滚约定。\n"
283-
284-
def prompt(self) -> str:
285-
return f"请根据你记得的项目约定,为「{self.project}」拟一份简短上线安排,涵盖开始时间、异常联系和回滚处理。不要执行发布;缺少信息时请明确说明。"
286-
287-
def expected(self) -> list[str]:
288-
return [self.release_time, self.contact, self.rollback_version]
289-
290-
291184
def run_cli(mode: str, client_type: Any, scenarios: dict[str, Callable[[Any, Run], None]]) -> None:
292185
parser = argparse.ArgumentParser(description=f"Qoder {mode} SDK examples")
293186
parser.add_argument("--env", default=".env.live")

‎examples/conftest.py‎

Lines changed: 0 additions & 25 deletions
This file was deleted.

‎examples/forward/batch.py‎

Lines changed: 19 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""上传 JSONL 批量任务,等待完成并核对任务、输出与会话回复。
1+
"""上传 JSONL 批量任务,等待完成并打印任务状态与输出。
22
33
运行:python -m examples.forward.batch
44
"""
@@ -10,7 +10,7 @@
1010

1111
import httpx
1212

13-
from examples.common.live import Run, choose_model, marker, name, run_cli, wait_reply
13+
from examples.common.live import Run, choose_model, name, run_cli
1414
from qca import Forward
1515

1616
from ._cleanup import finish_session
@@ -34,12 +34,12 @@ def run(client: Forward, context: Run) -> None:
3434
)
3535
template_id = context.track("template", template.id, lambda: client.templates.archive(template.id))
3636

37-
expected, custom_id = marker(), name("task")
37+
custom_id = name("task")
3838
data = {
3939
"custom_id": custom_id,
4040
"template_id": template_id,
4141
"identity_id": identity_id,
42-
"body": {"input": "Reply with exactly " + expected},
42+
"body": {"input": "请用一句话打个招呼。"},
4343
}
4444
input_file = client.files.upload(
4545
file=("input.jsonl", (json.dumps(data) + "\n").encode()), purpose="session_resource"
@@ -60,60 +60,32 @@ def cleanup_batch() -> None:
6060
while current.status not in terminal:
6161
context.pause()
6262
current = client.batches.retrieve(batch.id)
63-
if not current.output_file_id:
64-
if current.request_counts and current.request_counts.total == 0:
65-
return
66-
raise AssertionError("Batch has no output for session cleanup")
67-
rows = batch_rows(client, batch.id)
68-
if (
69-
len(rows) != 1
70-
or rows[0].get("custom_id") != custom_id
71-
or rows[0].get("identity_id") != identity_id
72-
or rows[0].get("template_id") != template_id
73-
):
74-
raise AssertionError("Batch cleanup output does not match this run")
75-
if rows[0].get("session_id"):
76-
finish_session(client, context, rows[0]["session_id"])
63+
if current.output_file_id:
64+
for row in batch_rows(client, batch.id):
65+
if row.get("session_id"):
66+
finish_session(client, context, row["session_id"])
7767

7868
context.track("batch", batch.id, cleanup_batch)
7969
while batch.status not in terminal:
8070
context.pause()
8171
batch = client.batches.retrieve(batch.id)
82-
if (
83-
batch.status != "completed"
84-
or not batch.request_counts
85-
or batch.request_counts.completed != 1
86-
or batch.request_counts.failed != 0
87-
or not batch.output_file_id
88-
):
89-
raise AssertionError("Batch did not complete exactly one successful task")
72+
context.output("batch_status", batch.status)
73+
if batch.request_counts:
74+
context.output(
75+
"request_counts",
76+
{"completed": batch.request_counts.completed, "failed": batch.request_counts.failed},
77+
)
9078
tasks = client.batches.tasks.list(batch.id)
91-
if len(tasks.data) != 1 or tasks.data[0].custom_id != custom_id:
92-
raise AssertionError("Batch task did not round trip")
93-
rows = batch_rows(client, batch.id)
94-
if len(rows) != 1:
95-
raise AssertionError("Expected one Batch output row")
96-
row = rows[0]
97-
context.output("batch_output", row)
98-
if (
99-
row.get("custom_id") != custom_id
100-
or row.get("identity_id") != identity_id
101-
or row.get("template_id") != template_id
102-
or row.get("status") != "completed"
103-
or row.get("error")
104-
or not row.get("session_id")
105-
):
106-
raise AssertionError("Batch output ownership or status mismatch")
107-
if expected not in json.dumps(row.get("response")):
108-
raise AssertionError("Batch response does not contain expected output")
109-
wait_reply(client.sessions.events, context, row["session_id"]).verify([expected])
79+
context.output("tasks", [task.custom_id for task in tasks.data])
80+
if batch.output_file_id:
81+
context.output("batch_output", batch_rows(client, batch.id))
11082

11183

11284
def batch_rows(client: Forward, batch_id: str) -> list[dict[str, Any]]:
11385
link = client.batches.retrieve_output(batch_id)
11486
url = httpx.URL(link.url)
11587
if url.scheme not in ("http", "https") or not url.host or url.userinfo:
116-
raise AssertionError("Invalid Batch output URL")
88+
raise RuntimeError("Invalid Batch output URL")
11789
# A separate HTTP client prevents API credentials from reaching storage.
11890
with httpx.Client(timeout=30, follow_redirects=True) as download:
11991
with download.stream("GET", url) as response:
@@ -122,7 +94,7 @@ def batch_rows(client: Forward, batch_id: str) -> list[dict[str, Any]]:
12294
for chunk in response.iter_bytes():
12395
content.extend(chunk)
12496
if len(content) > 4 * 1024 * 1024:
125-
raise AssertionError("Batch output exceeds the example's 4 MiB limit")
97+
raise RuntimeError("Batch output exceeds the example's 4 MiB limit")
12698
return [json.loads(line) for line in content.splitlines() if line.strip()]
12799

128100

‎examples/forward/memory.py‎

Lines changed: 46 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,47 @@
1-
"""写入项目记忆,在新会话中挂载并验证助手按记忆回答。
1+
"""写入项目记忆并挂载到新会话,发送消息并打印助手回复。
22
33
运行:python -m examples.forward.memory
44
"""
55

66
from __future__ import annotations
77

8-
from examples.common.live import ProjectMemory, Run, choose_model, name, run_cli, turn
8+
from examples.common.live import Run, choose_model, name, run_cli
99
from qca import Forward
1010

1111
from ._cleanup import finish_session
1212

13+
# 一段临时的项目记忆,仅用于演示如何写入并挂载 Memory Store。
14+
MEMORY_PATH = "projects/release-conventions.md"
15+
MEMORY_DOC = (
16+
"---\n"
17+
"name: release-conventions\n"
18+
"description: 青禾订单 的项目发布约定\n"
19+
"metadata:\n"
20+
" type: project\n"
21+
"---\n\n"
22+
"# 青禾订单\n\n"
23+
"- 北京时间 22:30 开始发布。\n"
24+
"- 发布异常时联系值班负责人林岚。\n"
25+
"- 回滚使用已验证的稳定版本 v2.480.15。\n\n"
26+
"Why: 在值班窗口发布,并使用验证过的版本恢复服务。\n"
27+
"How to apply: 为这个项目拟定发布计划时遵循以上约定。\n"
28+
)
29+
MEMORY_INDEX = f"- [青禾订单 发布约定]({MEMORY_PATH}) — 发布窗口、异常联系人与回滚约定。\n"
30+
MEMORY_PROMPT = "请根据你记得的项目约定,为「青禾订单」拟一份简短上线安排,涵盖开始时间、异常联系和回滚处理。不要执行发布;缺少信息时请明确说明。"
31+
1332

1433
def run(client: Forward, context: Run) -> None:
1534
environment = client.environments.create(name=name("env"), config={"type": "cloud"})
1635
environment_id = context.track("environment", environment.id, lambda: client.environments.archive(environment.id))
1736

1837
identity = client.identities.create(external_id=name("identity"), name="SDK 示例用户")
38+
identity_id = context.track("identity", identity.id, lambda: client.identities.delete(identity.id))
1939

20-
def cleanup_identity() -> None:
21-
result = client.identities.clear(identity.id, reason="SDK example cleanup")
22-
if result.status != "completed":
23-
raise AssertionError("Dedicated Identity cleanup did not complete")
24-
client.identities.delete(identity.id)
25-
26-
identity_id = context.track("identity", identity.id, cleanup_identity)
27-
28-
memory = ProjectMemory()
2940
store = client.memory_stores.create(name=name("memory"), idempotency_key=name("memory-key"))
3041
context.track("memory_store", store.id, lambda: client.memory_stores.delete(store.id))
31-
for path, content in ((memory.path, memory.content()), ("MEMORY.md", memory.index())):
32-
entry = client.memory_stores.memories.create(store.id, path=path, content=content)
33-
saved = client.memory_stores.memories.retrieve(entry.id, memory_store_id=store.id)
34-
if saved.content != content:
35-
raise AssertionError("Persisted memory does not match the supplied content")
42+
for path, content in ((MEMORY_PATH, MEMORY_DOC), ("MEMORY.md", MEMORY_INDEX)):
43+
client.memory_stores.memories.create(store.id, path=path, content=content)
44+
3645
model = choose_model(client.models.list(), context.config.model)
3746
context.output("selected_model", model)
3847
template = client.templates.create(
@@ -50,16 +59,33 @@ def cleanup_identity() -> None:
5059
store.id,
5160
lambda: client.identities.memory_stores.detach(store.id, template_id=template_id, identity_id=identity_id),
5261
)
53-
mounts = client.identities.memory_stores.list(template_id, identity_id=identity_id)
54-
if not any(mount.memory_store_id == store.id for mount in mounts.data):
55-
raise AssertionError("Memory Store binding was not persisted")
5662
session = client.sessions.create(
5763
identity_id=identity_id,
5864
template_id=template_id,
5965
)
6066
session_id = context.track("session", session.id, lambda: finish_session(client, context, session.id))
6167

62-
turn(client.sessions.events, context, session_id, memory.prompt(), memory.expected())
68+
context.output("user", MEMORY_PROMPT)
69+
sent = client.sessions.events.send(
70+
session_id,
71+
events=[{"type": "user.message", "content": [{"type": "text", "text": MEMORY_PROMPT}]}],
72+
extra_headers={"Idempotency-Key": name("event")},
73+
)
74+
if not sent.data or not sent.data[0].id:
75+
raise RuntimeError("Send returned no user event")
76+
with client.sessions.events.stream(
77+
session_id,
78+
extra_headers={"Last-Event-ID": sent.data[0].id},
79+
timeout=context.remaining(),
80+
) as stream:
81+
for event in stream:
82+
context.remaining()
83+
if event.type == "agent.message":
84+
context.output("assistant", event.to_json())
85+
elif event.type in ("session.error", "session.status_terminated"):
86+
raise RuntimeError(f"Session stopped: {event.type}")
87+
elif event.type == "session.status_idle":
88+
break
6389

6490

6591
if __name__ == "__main__":

0 commit comments

Comments
 (0)