codeer-cli 0.1.6__tar.gz → 0.1.7__tar.gz

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 (29) hide show
  1. {codeer_cli-0.1.6 → codeer_cli-0.1.7}/API_REFERENCE.md +29 -25
  2. {codeer_cli-0.1.6 → codeer_cli-0.1.7}/PKG-INFO +2 -2
  3. {codeer_cli-0.1.6 → codeer_cli-0.1.7}/README.md +1 -1
  4. {codeer_cli-0.1.6 → codeer_cli-0.1.7}/pyproject.toml +1 -1
  5. {codeer_cli-0.1.6 → codeer_cli-0.1.7}/src/codeer_cli/commands/eval_cmd.py +151 -19
  6. {codeer_cli-0.1.6 → codeer_cli-0.1.7}/src/codeer_cli/eval_.py +51 -8
  7. codeer_cli-0.1.7/tests/test_eval_pairs.py +125 -0
  8. {codeer_cli-0.1.6 → codeer_cli-0.1.7}/uv.lock +1 -1
  9. {codeer_cli-0.1.6 → codeer_cli-0.1.7}/.gitignore +0 -0
  10. {codeer_cli-0.1.6 → codeer_cli-0.1.7}/src/codeer_cli/__init__.py +0 -0
  11. {codeer_cli-0.1.6 → codeer_cli-0.1.7}/src/codeer_cli/_validate.py +0 -0
  12. {codeer_cli-0.1.6 → codeer_cli-0.1.7}/src/codeer_cli/agents.py +0 -0
  13. {codeer_cli-0.1.6 → codeer_cli-0.1.7}/src/codeer_cli/chats.py +0 -0
  14. {codeer_cli-0.1.6 → codeer_cli-0.1.7}/src/codeer_cli/cli.py +0 -0
  15. {codeer_cli-0.1.6 → codeer_cli-0.1.7}/src/codeer_cli/client.py +0 -0
  16. {codeer_cli-0.1.6 → codeer_cli-0.1.7}/src/codeer_cli/commands/__init__.py +0 -0
  17. {codeer_cli-0.1.6 → codeer_cli-0.1.7}/src/codeer_cli/commands/_util.py +0 -0
  18. {codeer_cli-0.1.6 → codeer_cli-0.1.7}/src/codeer_cli/commands/agent.py +0 -0
  19. {codeer_cli-0.1.6 → codeer_cli-0.1.7}/src/codeer_cli/commands/check.py +0 -0
  20. {codeer_cli-0.1.6 → codeer_cli-0.1.7}/src/codeer_cli/commands/history.py +0 -0
  21. {codeer_cli-0.1.6 → codeer_cli-0.1.7}/src/codeer_cli/commands/kb.py +0 -0
  22. {codeer_cli-0.1.6 → codeer_cli-0.1.7}/src/codeer_cli/commands/profile.py +0 -0
  23. {codeer_cli-0.1.6 → codeer_cli-0.1.7}/src/codeer_cli/constants.py +0 -0
  24. {codeer_cli-0.1.6 → codeer_cli-0.1.7}/src/codeer_cli/histories.py +0 -0
  25. {codeer_cli-0.1.6 → codeer_cli-0.1.7}/src/codeer_cli/kb.py +0 -0
  26. {codeer_cli-0.1.6 → codeer_cli-0.1.7}/src/codeer_cli/parse.py +0 -0
  27. {codeer_cli-0.1.6 → codeer_cli-0.1.7}/tests/test_eval_labels.py +0 -0
  28. {codeer_cli-0.1.6 → codeer_cli-0.1.7}/tests/test_kb_nodes.py +0 -0
  29. {codeer_cli-0.1.6 → codeer_cli-0.1.7}/tests/test_kb_ranges.py +0 -0
@@ -164,9 +164,11 @@ for the apply → test → publish workflow. Pass the draft `AgentHistory.id` fr
164
164
  | `GET /eval/evaluators?wid=<ws>` | List evaluators |
165
165
  | `PUT /eval/evaluators/{id}` | Update |
166
166
  | `DELETE /eval/evaluators/{id}` | Delete |
167
- | `POST /eval/trigger` | Run a set of cases with evaluators, optionally pinned to `agent_history_id` |
167
+ | `POST /eval/case-evaluator-infos:batch` | Read assigned evaluators/rubrics for cases |
168
+ | `PUT /eval/cases/{case_id}/case-evaluator-infos` | Replace assigned evaluators/rubrics for one case |
169
+ | `POST /eval/trigger` | Run explicit assigned `case_evaluator_pairs` pinned to `agent_history_id` |
168
170
  | `POST /eval/stop` | Cancel running case+evaluator combo |
169
- | `POST /eval/rubric` | Set/override the rubric for one (case, evaluator) write-only |
171
+ | `POST /eval/rubric` | Set/override the rubric for one (case, evaluator); also creates assignment |
170
172
  | `POST /eval/rubrics/batch` | **Read** rubrics for a batch of (case, evaluator) pairs |
171
173
 
172
174
  Eval case labels are workspace-scoped reusable objects. The case create/update
@@ -220,17 +222,17 @@ normalized `tool_calls`, `tool_calls_summary`, and `tool_total_duration_ms`;
220
222
  in `eval_table_full.json`. Per-tool time is computed from `start_at/end_at`.
221
223
 
222
224
  **`evaluator_id` is singular — one call returns results for one evaluator
223
- only.** To see the full picture for a case, you must call this endpoint
224
- once per evaluator. A case might score 1.0 on Style/Tone but 0.3 on
225
- Content Compliance checking only one evaluator hides the other failure.
226
- Always iterate all evaluators in the workspace (or at least all evaluators
227
- the agent's cases are judged by). `codeer eval run` and `codeer eval rubrics`
228
- handle this automatically; if calling the API directly, loop over
229
- `eval_mod.list_evaluators(workspace_id)` and call `get_results()` for each.
230
-
231
- Regression workflow (apply prompt change → re-run all cases spot side
232
- effects): `codeer eval run --agent <agent_id>` runs the latest AgentHistory
233
- by default. Review the full result set against the previous run summary.
225
+ only.** Cases are also bound to specific evaluator assignments. To see the
226
+ full picture for a case, first read the case/evaluator assignments, then call
227
+ results once per assigned evaluator. `codeer eval run` and
228
+ `codeer eval rubrics` handle this automatically; if calling the API directly,
229
+ prefer `eval_mod.get_case_evaluator_infos(case_ids=[...])` as the source of
230
+ truth for which pairs should run.
231
+
232
+ Regression workflow (apply prompt change → re-run all assigned pairs → spot
233
+ side effects): `codeer eval run --agent <agent_id>` runs the latest
234
+ AgentHistory by default. For the common "many cases, one tester" flow, use
235
+ `codeer eval run --agent <agent_id> --cases <ids> --evaluator <evaluator_id>`.
234
236
 
235
237
  ## Stage 7 — Publish
236
238
 
@@ -305,10 +307,12 @@ analytics/column name, `question` is the user-facing prompt.
305
307
  ### 3. The `Standard` shown in Test Suite is a per-(case, evaluator) rubric
306
308
 
307
309
  `POST /eval/cases` has a `rubric` field — this is NOT what the Test Suite's
308
- `Standard` column reads. That column is populated by `POST /eval/rubric` keyed
309
- on `(evaluation_case_id, evaluator_id)`. Set it explicitly for each
310
- (case, evaluator) pair after creating the case, or use the eval helpers in
311
- `codeer-cli/src/codeer_cli/eval_.py` which do both in one call.
310
+ `Standard` column reads. That column is populated by `CaseEvaluatorInfo` keyed
311
+ on `(evaluation_case_id, evaluator_id)`. The same row is also the assignment
312
+ that makes the pair eligible to run. Set it explicitly for each
313
+ (case, evaluator) pair, or use the eval helpers in
314
+ `codeer-cli/src/codeer_cli/eval_.py` which create assignments and rubrics
315
+ together.
312
316
 
313
317
  To **read** rubrics back, use `POST /eval/rubrics/batch` with
314
318
  `{case_ids: [...], evaluator_id}` — it returns the raw rubric strings out of
@@ -477,22 +481,22 @@ If the user's source tree has deeper nesting, flatten it first with the
477
481
  `products/a.md` using `/` U+FF0F as separator) so `list_kb_files` regex
478
482
  can still recover structure. That skill's docs explain the full flow.
479
483
 
480
- ### 13. Eval results and rubrics are **per-evaluator** always iterate all evaluators
484
+ ### 13. Eval results and rubrics are **per assigned case/evaluator pair**
481
485
 
482
486
  Both `POST /eval/results/batch` and `POST /eval/rubrics/batch` take a
483
487
  **singular** `evaluator_id`, not a list. Each call returns data for one
484
- evaluator only. A common mistake is to check results for one evaluator
485
- (e.g. Content Compliance), see a perfect score, and conclude the case is
486
- passing — while the other evaluator (e.g. Style/Tone) scored it 0.3.
488
+ evaluator only. A case only runs with evaluators it is assigned to through
489
+ `CaseEvaluatorInfo`.
487
490
 
488
491
  When pulling eval data manually, always:
489
- 1. List all evaluators: `eval_mod.list_evaluators(workspace_id)`.
490
- 2. Call `get_results()` or `get_rubrics_batch()` once **per evaluator**.
491
- 3. Join the results by `case_id` to get the full (case × evaluator) matrix.
492
+ 1. Read assignments with `eval_mod.get_case_evaluator_infos(case_ids=[...])`.
493
+ 2. Call `get_results()` or `get_rubrics_batch()` once per assigned evaluator.
494
+ 3. Join the results by `(case_id, evaluator_id)`.
492
495
 
493
496
  The CLI commands (`codeer eval run`, `codeer eval rubrics`,
494
497
  `codeer eval rubrics-apply`) and the `get_case_rubrics()` helper all handle
495
- this iteration automatically. Prefer them for normal operations.
498
+ this iteration automatically. Use `codeer eval rubrics --all-pairs` only when
499
+ you intentionally want the old full matrix scan.
496
500
 
497
501
  ---
498
502
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codeer-cli
3
- Version: 0.1.6
3
+ Version: 0.1.7
4
4
  Summary: Command line tools for managing Codeer agents over the Codeer API.
5
5
  Project-URL: Homepage, https://www.codeer.ai
6
6
  Author: Codeer.AI
@@ -143,7 +143,7 @@ Use this pattern during agent lifecycle work:
143
143
  ```bash
144
144
  codeer agent list
145
145
  codeer history list --agent <agent-id> --limit 50
146
- codeer eval run --agent <agent-id> --evaluators <evaluator-id> --out .codeer/eval_run.json
146
+ codeer eval run --agent <agent-id> --cases <case-ids> --evaluator <evaluator-id> --out .codeer/eval_run.json
147
147
  ```
148
148
 
149
149
  Flags:
@@ -125,7 +125,7 @@ Use this pattern during agent lifecycle work:
125
125
  ```bash
126
126
  codeer agent list
127
127
  codeer history list --agent <agent-id> --limit 50
128
- codeer eval run --agent <agent-id> --evaluators <evaluator-id> --out .codeer/eval_run.json
128
+ codeer eval run --agent <agent-id> --cases <case-ids> --evaluator <evaluator-id> --out .codeer/eval_run.json
129
129
  ```
130
130
 
131
131
  Flags:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "codeer-cli"
7
- version = "0.1.6"
7
+ version = "0.1.7"
8
8
  description = "Command line tools for managing Codeer agents over the Codeer API."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -154,7 +154,9 @@ def register(subparsers):
154
154
  g.add_argument("--latest", action="store_true",
155
155
  help="Auto-select the newest AgentHistory (default)")
156
156
  p.add_argument("--cases", default=None, help="Comma-separated case UUIDs (default: all)")
157
- p.add_argument("--evaluators", required=True, help="Comma-separated evaluator UUIDs")
157
+ g = p.add_mutually_exclusive_group()
158
+ g.add_argument("--evaluator", default=None, help="Evaluator UUID; common path for running many cases with one tester")
159
+ g.add_argument("--evaluators", default=None, help="Comma-separated evaluator UUIDs")
158
160
  p.add_argument("--poll-timeout", type=int, default=POLL_TIMEOUT)
159
161
  p.add_argument("--full", action="store_true",
160
162
  help="Use longer previews in stdout. Raw outputs/tool calls still require --out.")
@@ -195,10 +197,12 @@ def register(subparsers):
195
197
  p.set_defaults(func=run_cases_apply)
196
198
 
197
199
  # codeer eval rubrics
198
- p = sub.add_parser("rubrics", help="Read per-(case, evaluator) rubrics")
200
+ p = sub.add_parser("rubrics", help="Read assigned per-(case, evaluator) rubrics")
199
201
  p.add_argument("--agent", required=True)
200
202
  p.add_argument("--evaluators", default=None, help="Comma-separated evaluator UUIDs")
201
203
  p.add_argument("--cases", default=None, help="Comma-separated case UUIDs")
204
+ p.add_argument("--all-pairs", action="store_true",
205
+ help="With omitted --evaluators, scan every workspace evaluator instead of assigned pairs only.")
202
206
  p.add_argument("--full", action="store_true",
203
207
  help="Print complete rubric text. Default prints matrix summaries/previews.")
204
208
  p.add_argument("--out", default=None,
@@ -599,6 +603,56 @@ def run_evaluator_update(args, client) -> int:
599
603
  # eval run
600
604
  # ---------------------------------------------------------------------------
601
605
 
606
+ def _assigned_evaluators_by_case(info_rows: list[dict]) -> dict[str, dict[str, dict]]:
607
+ out: dict[str, dict[str, dict]] = {}
608
+ for row in info_rows:
609
+ case_id = row.get("case_id")
610
+ if not case_id:
611
+ continue
612
+ out[str(case_id)] = {
613
+ str(info.get("evaluator_id")): info
614
+ for info in (row.get("evaluators") or [])
615
+ if info.get("evaluator_id")
616
+ }
617
+ return out
618
+
619
+
620
+ def _planned_eval_pairs(
621
+ *,
622
+ case_ids: list[str],
623
+ assigned_by_case: dict[str, dict[str, dict]],
624
+ requested_evaluator_ids: list[str] | None,
625
+ ) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
626
+ pairs: list[dict[str, str]] = []
627
+ skipped: list[dict[str, str]] = []
628
+
629
+ if requested_evaluator_ids:
630
+ for case_id in case_ids:
631
+ assigned = assigned_by_case.get(case_id, {})
632
+ for evaluator_id in requested_evaluator_ids:
633
+ if evaluator_id in assigned:
634
+ pairs.append({"case_id": case_id, "evaluator_id": evaluator_id})
635
+ else:
636
+ skipped.append({
637
+ "case_id": case_id,
638
+ "evaluator_id": evaluator_id,
639
+ "reason": "not_assigned",
640
+ })
641
+ return pairs, skipped
642
+
643
+ for case_id in case_ids:
644
+ for evaluator_id in assigned_by_case.get(case_id, {}):
645
+ pairs.append({"case_id": case_id, "evaluator_id": evaluator_id})
646
+ return pairs, skipped
647
+
648
+
649
+ def _group_case_ids_by_evaluator(pairs: list[dict[str, str]]) -> dict[str, list[str]]:
650
+ grouped: dict[str, list[str]] = defaultdict(list)
651
+ for pair in pairs:
652
+ grouped[pair["evaluator_id"]].append(pair["case_id"])
653
+ return dict(grouped)
654
+
655
+
602
656
  def run_run(args, client) -> int:
603
657
  workspace_id, _ = client.resolve_scope()
604
658
  if args.latest or not args.history:
@@ -625,41 +679,76 @@ def run_run(args, client) -> int:
625
679
  log("error: no cases to run")
626
680
  return 2
627
681
 
628
- evaluator_ids = _ids(args.evaluators) or []
629
- if not evaluator_ids:
630
- log("error: --evaluators is required")
682
+ evaluator_ids = [args.evaluator] if args.evaluator else (_ids(args.evaluators) or [])
683
+ requested_evaluator_ids = evaluator_ids or None
684
+
685
+ assignment_rows = eval_mod.get_case_evaluator_infos(client, case_ids=case_ids)
686
+ assigned_by_case = _assigned_evaluators_by_case(assignment_rows)
687
+ pairs, skipped_unassigned = _planned_eval_pairs(
688
+ case_ids=case_ids,
689
+ assigned_by_case=assigned_by_case,
690
+ requested_evaluator_ids=requested_evaluator_ids,
691
+ )
692
+ if not pairs:
693
+ if skipped_unassigned:
694
+ log("error: none of the requested case/evaluator pairs are assigned")
695
+ else:
696
+ log("error: no assigned case/evaluator pairs to run")
697
+ print_json({
698
+ "agent_id": args.agent,
699
+ "history_id": args.history,
700
+ "requested_case_count": len(case_ids),
701
+ "requested_evaluator_count": len(evaluator_ids),
702
+ "triggered_pair_count": 0,
703
+ "skipped_unassigned": skipped_unassigned,
704
+ })
631
705
  return 2
706
+
707
+ evaluator_ids = _dedupe_preserve_order([pair["evaluator_id"] for pair in pairs])
632
708
  evaluators = [eval_mod.get_evaluator(client, eid) for eid in evaluator_ids]
633
709
 
634
710
  case_label_by_id = {c["id"]: truncate(c.get("input") or "", 60) for c in case_objs}
635
711
  evaluator_name_by_id = {e["id"]: e.get("name", e["id"]) for e in evaluators}
636
712
 
637
- log(f"triggering: {len(case_ids)} cases x {len(evaluator_ids)} evaluators on history {args.history}")
638
- eval_mod.trigger(client, case_ids=case_ids, evaluator_ids=evaluator_ids,
639
- agent_history_id=args.history)
713
+ if skipped_unassigned:
714
+ log(f"skipping {len(skipped_unassigned)} unassigned requested pairs")
715
+ log(f"triggering: {len(pairs)} assigned case/evaluator pairs on history {args.history}")
716
+ trigger_response = eval_mod.trigger_pairs(
717
+ client,
718
+ case_evaluator_pairs=pairs,
719
+ agent_history_id=args.history,
720
+ )
640
721
 
641
722
  deadline = time.time() + args.poll_timeout
642
723
  results_by_eval: dict[str, list[dict]] = {}
724
+ case_ids_by_evaluator = _group_case_ids_by_evaluator(pairs)
725
+ target_pair_keys = {(pair["case_id"], pair["evaluator_id"]) for pair in pairs}
643
726
  while time.time() < deadline:
644
727
  results_by_eval = {}
645
- done = 0
646
- total = len(case_ids) * len(evaluator_ids)
647
- for ev_id in evaluator_ids:
728
+ done_pairs: set[tuple[str, str]] = set()
729
+ total = len(pairs)
730
+ for ev_id, ev_case_ids in case_ids_by_evaluator.items():
648
731
  rows = eval_mod.get_results(
649
- client, case_ids=case_ids, evaluator_id=ev_id,
732
+ client, case_ids=ev_case_ids, evaluator_id=ev_id,
650
733
  agent_history_id=args.history, workspace_id=workspace_id,
651
734
  include_output=True, include_reasoning_steps=True,
652
735
  )
653
736
  results_by_eval[ev_id] = rows
654
- done += sum(1 for r in rows if r.get("score") is not None)
655
- log(f" progress: {done}/{total}")
656
- if done >= total:
737
+ for row in rows:
738
+ key = (row.get("case_id") or row.get("evaluation_case_id"), ev_id)
739
+ if key in target_pair_keys and row.get("score") is not None:
740
+ done_pairs.add(key)
741
+ log(f" progress: {len(done_pairs)}/{total}")
742
+ if len(done_pairs) >= total:
657
743
  break
658
744
  time.sleep(POLL_INTERVAL)
659
745
 
660
746
  flat: list[dict] = []
661
747
  for ev_id, rows in results_by_eval.items():
662
748
  for r in rows:
749
+ row_case_id = r.get("case_id") or r.get("evaluation_case_id")
750
+ if (row_case_id, ev_id) not in target_pair_keys:
751
+ continue
663
752
  result_summary = parse_eval_result(r)
664
753
  tool_calls = parse_eval_tool_calls(r)
665
754
  total_tool_duration_ms = sum(
@@ -684,7 +773,15 @@ def run_run(args, client) -> int:
684
773
  "raw_result": r,
685
774
  })
686
775
 
687
- all_perfect = all((r.get("score") or 0.0) >= 1.0 for r in flat) if flat else False
776
+ scored_pair_keys = {
777
+ (r.get("case_id"), r.get("evaluator_id"))
778
+ for r in flat
779
+ if r.get("score") is not None
780
+ }
781
+ all_perfect = (
782
+ len(scored_pair_keys) == len(target_pair_keys)
783
+ and all((r.get("score") or 0.0) >= 1.0 for r in flat)
784
+ )
688
785
  log("\n" + "=" * 80)
689
786
  log(f"RESULTS agent={args.agent} history={args.history}")
690
787
  log("=" * 80)
@@ -726,15 +823,25 @@ def run_run(args, client) -> int:
726
823
  out = {
727
824
  "agent_id": args.agent,
728
825
  "history_id": args.history,
826
+ "requested_case_count": len(case_ids),
827
+ "requested_evaluator_count": len(requested_evaluator_ids or evaluator_ids),
828
+ "triggered_pair_count": len(pairs),
829
+ "scored_pair_count": len(scored_pair_keys),
830
+ "skipped_unassigned_count": len(skipped_unassigned),
729
831
  "all_perfect": all_perfect,
730
832
  "result_count": len(result_summaries),
731
833
  "non_perfect_count": len(non_perfect),
732
834
  "wrote_full_detail": bool(args.out),
835
+ "trigger_response": trigger_response,
836
+ "skipped_unassigned": skipped_unassigned,
733
837
  "results": result_summaries,
734
838
  }
735
839
  full_out = {
736
840
  "agent_id": args.agent,
737
841
  "history_id": args.history,
842
+ "triggered_pairs": pairs,
843
+ "trigger_response": trigger_response,
844
+ "skipped_unassigned": skipped_unassigned,
738
845
  "all_perfect": all_perfect,
739
846
  "results": flat,
740
847
  }
@@ -1359,18 +1466,29 @@ def run_rubrics(args, client) -> int:
1359
1466
  log("error: no cases for this agent")
1360
1467
  return 2
1361
1468
 
1469
+ assignment_rows = eval_mod.get_case_evaluator_infos(client, case_ids=case_ids)
1470
+ assigned_by_case = _assigned_evaluators_by_case(assignment_rows)
1471
+
1362
1472
  if args.evaluators:
1363
1473
  evaluator_ids = _ids(args.evaluators) or []
1364
1474
  evaluators = [eval_mod.get_evaluator(client, eid) for eid in evaluator_ids]
1365
- else:
1475
+ elif args.all_pairs:
1366
1476
  evaluators = eval_mod.list_evaluators(client, workspace_id)
1367
1477
  evaluator_ids = [e["id"] for e in evaluators]
1478
+ else:
1479
+ evaluator_ids = _dedupe_preserve_order([
1480
+ evaluator_id
1481
+ for case_id in case_ids
1482
+ for evaluator_id in assigned_by_case.get(case_id, {})
1483
+ ])
1484
+ evaluators = [eval_mod.get_evaluator(client, eid) for eid in evaluator_ids]
1368
1485
  evaluator_name = {e["id"]: e.get("name", e["id"]) for e in evaluators}
1369
1486
  if not evaluator_ids:
1370
- log("error: no evaluators in workspace")
1487
+ log("error: no assigned evaluators for these cases")
1371
1488
  return 2
1372
1489
 
1373
- log(f"reading {len(case_ids)} cases x {len(evaluator_ids)} evaluators...")
1490
+ mode = "all requested pairs" if args.evaluators or args.all_pairs else "assigned pairs"
1491
+ log(f"reading {mode}: {len(case_ids)} cases, {len(evaluator_ids)} evaluators...")
1374
1492
 
1375
1493
  rubrics = eval_mod.get_case_rubrics(
1376
1494
  client, agent_id=args.agent, workspace_id=workspace_id,
@@ -1383,8 +1501,14 @@ def run_rubrics(args, client) -> int:
1383
1501
  log(f"CASE {cid}")
1384
1502
  log(f" input: {truncate(case_input.get(cid, ''), 120)}")
1385
1503
  for ev_id in evaluator_ids:
1504
+ is_assigned = ev_id in assigned_by_case.get(cid, {})
1505
+ if not is_assigned and not (args.evaluators or args.all_pairs):
1506
+ continue
1386
1507
  ev_name = evaluator_name.get(ev_id, ev_id)
1387
1508
  rubric_text = (rubrics.get(cid) or {}).get(ev_id, "")
1509
+ if not is_assigned:
1510
+ log(f" [{ev_name}] (not assigned)")
1511
+ continue
1388
1512
  if not rubric_text:
1389
1513
  log(f" [{ev_name}] (rubric not set)")
1390
1514
  else:
@@ -1396,9 +1520,13 @@ def run_rubrics(args, client) -> int:
1396
1520
  for cid in case_ids:
1397
1521
  rubrics_summary = {}
1398
1522
  for ev_id in evaluator_ids:
1523
+ is_assigned = ev_id in assigned_by_case.get(cid, {})
1524
+ if not is_assigned and not (args.evaluators or args.all_pairs):
1525
+ continue
1399
1526
  rubric_text = (rubrics.get(cid) or {}).get(ev_id, "")
1400
1527
  rubrics_summary[ev_id] = {
1401
1528
  "evaluator_name": evaluator_name.get(ev_id, ev_id),
1529
+ "is_assigned": is_assigned,
1402
1530
  "is_set": bool(rubric_text),
1403
1531
  "chars": len(rubric_text),
1404
1532
  "preview": truncate(rubric_text, 240),
@@ -1412,6 +1540,7 @@ def run_rubrics(args, client) -> int:
1412
1540
  out = {
1413
1541
  "agent_id": args.agent,
1414
1542
  "workspace_id": workspace_id,
1543
+ "mode": mode,
1415
1544
  "evaluators": [{"id": e["id"], "name": e.get("name")} for e in evaluators],
1416
1545
  "cases": [
1417
1546
  {
@@ -1420,7 +1549,9 @@ def run_rubrics(args, client) -> int:
1420
1549
  "rubrics_by_evaluator": {
1421
1550
  ev_id: (rubrics.get(cid) or {}).get(ev_id)
1422
1551
  for ev_id in evaluator_ids
1552
+ if ev_id in assigned_by_case.get(cid, {}) or args.evaluators or args.all_pairs
1423
1553
  },
1554
+ "assigned_evaluator_ids": list(assigned_by_case.get(cid, {})),
1424
1555
  }
1425
1556
  for cid in case_ids
1426
1557
  ],
@@ -1432,6 +1563,7 @@ def run_rubrics(args, client) -> int:
1432
1563
  print_json({
1433
1564
  "agent_id": args.agent,
1434
1565
  "workspace_id": workspace_id,
1566
+ "mode": mode,
1435
1567
  "evaluator_count": len(evaluators),
1436
1568
  "case_count": len(case_ids),
1437
1569
  "wrote_full_detail": bool(args.out),
@@ -23,6 +23,7 @@ def create_case(
23
23
  rubric: Optional[str] = None,
24
24
  attachment_ids: Optional[List[str]] = None,
25
25
  label_ids: Optional[List[str]] = None,
26
+ evaluators: Optional[List[dict[str, Any]]] = None,
26
27
  meta: Optional[dict] = None,
27
28
  note: Optional[str] = None,
28
29
  ) -> dict:
@@ -44,6 +45,8 @@ def create_case(
44
45
  body["attachment_ids"] = attachment_ids
45
46
  if label_ids is not None:
46
47
  body["label_ids"] = label_ids
48
+ if evaluators is not None:
49
+ body["evaluators"] = evaluators
47
50
  if meta:
48
51
  body["meta"] = meta
49
52
  if note is not None:
@@ -93,6 +96,31 @@ def delete_case(client: CodeerClient, case_id: str) -> dict:
93
96
  return client.delete(f"/external/eval/cases/{case_id}")
94
97
 
95
98
 
99
+ # --- case/evaluator assignments ------------------------------------------
100
+
101
+ def get_case_evaluator_infos(client: CodeerClient, *, case_ids: List[str]) -> list[dict]:
102
+ """Read assigned evaluator metadata for each case.
103
+
104
+ Returns rows shaped like ``{"case_id": str, "evaluators": [...]}``, where
105
+ each evaluator entry is the assigned ``{"evaluator_id", "rubric"}`` pair.
106
+ """
107
+ return client.post("/eval/case-evaluator-infos:batch", json={"case_ids": case_ids})
108
+
109
+
110
+ def replace_case_evaluator_infos(
111
+ client: CodeerClient,
112
+ *,
113
+ case_id: str,
114
+ evaluators: list[dict[str, Any]],
115
+ ) -> dict:
116
+ """Replace a case's assigned evaluators.
117
+
118
+ This is intentionally separate from rubric upsert: replacing removes
119
+ evaluator assignments that are not included in ``evaluators``.
120
+ """
121
+ return client.put(f"/eval/cases/{case_id}/case-evaluator-infos", json={"evaluators": evaluators})
122
+
123
+
96
124
  # --- case labels -----------------------------------------------------------
97
125
 
98
126
  def list_case_labels(client: CodeerClient, *, workspace_id: str) -> list[dict]:
@@ -202,6 +230,22 @@ def trigger(
202
230
  return client.post("/external/eval/runs", json=body)
203
231
 
204
232
 
233
+ def trigger_pairs(
234
+ client: CodeerClient,
235
+ *,
236
+ case_evaluator_pairs: list[dict[str, str]],
237
+ agent_history_id: str,
238
+ ) -> dict:
239
+ """Kick off evaluation for explicit assigned case/evaluator pairs."""
240
+ return client.post(
241
+ "/eval/trigger",
242
+ json={
243
+ "case_evaluator_pairs": case_evaluator_pairs,
244
+ "agent_history_id": agent_history_id,
245
+ },
246
+ )
247
+
248
+
205
249
  def stop(client: CodeerClient, *, case_id: str, evaluator_id: str) -> Any:
206
250
  return client.post("/external/eval/runs:stop", json={"case_id": case_id, "evaluator_id": evaluator_id})
207
251
 
@@ -451,9 +495,9 @@ def create_case_with_rubrics(
451
495
  is filled in for every evaluator it will be judged by.
452
496
 
453
497
  ``rubrics_by_evaluator`` maps ``evaluator_id → rubric_text``. Each entry
454
- becomes a ``POST /eval/rubric`` call after the case is created. Use
455
- different rubric wording per evaluator when the evaluators judge different
456
- aspects (e.g. Style/Tone vs Content Compliance).
498
+ becomes a case/evaluator assignment on create. Use different rubric wording
499
+ per evaluator when the evaluators judge different aspects (e.g. Style/Tone
500
+ vs Content Compliance).
457
501
  """
458
502
  case = create_case(
459
503
  client,
@@ -462,12 +506,11 @@ def create_case_with_rubrics(
462
506
  expected_output=expected_output,
463
507
  attachment_ids=attachment_ids,
464
508
  label_ids=label_ids,
509
+ evaluators=[
510
+ {"evaluator_id": ev_id, "rubric": rubric}
511
+ for ev_id, rubric in rubrics_by_evaluator.items()
512
+ ],
465
513
  meta=meta,
466
514
  note=note,
467
515
  )
468
- set_rubric_bulk(
469
- client,
470
- evaluation_case_id=case["id"],
471
- rubrics_by_evaluator=rubrics_by_evaluator,
472
- )
473
516
  return case
@@ -0,0 +1,125 @@
1
+ from __future__ import annotations
2
+
3
+ import unittest
4
+
5
+ from codeer_cli import eval_ as eval_mod
6
+ from codeer_cli.commands.eval_cmd import _assigned_evaluators_by_case, _planned_eval_pairs
7
+
8
+
9
+ class FakeClient:
10
+ def __init__(self) -> None:
11
+ self.calls: list[tuple[str, str, dict]] = []
12
+
13
+ def post(self, path: str, **kwargs):
14
+ self.calls.append(("POST", path, kwargs))
15
+ if path == "/external/eval/cases":
16
+ return {"id": "case-1"}
17
+ return {"ok": True}
18
+
19
+ def put(self, path: str, **kwargs):
20
+ self.calls.append(("PUT", path, kwargs))
21
+ return {"ok": True}
22
+
23
+
24
+ class EvalPairClientTests(unittest.TestCase):
25
+ def test_assignment_helper_paths(self) -> None:
26
+ client = FakeClient()
27
+
28
+ eval_mod.get_case_evaluator_infos(client, case_ids=["case-1"]) # type: ignore[arg-type]
29
+ eval_mod.replace_case_evaluator_infos( # type: ignore[arg-type]
30
+ client,
31
+ case_id="case-1",
32
+ evaluators=[{"evaluator_id": "eval-1", "rubric": "Must pass"}],
33
+ )
34
+ eval_mod.trigger_pairs( # type: ignore[arg-type]
35
+ client,
36
+ case_evaluator_pairs=[{"case_id": "case-1", "evaluator_id": "eval-1"}],
37
+ agent_history_id="hist-1",
38
+ )
39
+
40
+ self.assertEqual(client.calls[0][0:2], ("POST", "/eval/case-evaluator-infos:batch"))
41
+ self.assertEqual(client.calls[0][2]["json"], {"case_ids": ["case-1"]})
42
+ self.assertEqual(client.calls[1][0:2], ("PUT", "/eval/cases/case-1/case-evaluator-infos"))
43
+ self.assertEqual(
44
+ client.calls[1][2]["json"],
45
+ {"evaluators": [{"evaluator_id": "eval-1", "rubric": "Must pass"}]},
46
+ )
47
+ self.assertEqual(client.calls[2][0:2], ("POST", "/eval/trigger"))
48
+ self.assertEqual(
49
+ client.calls[2][2]["json"],
50
+ {
51
+ "case_evaluator_pairs": [{"case_id": "case-1", "evaluator_id": "eval-1"}],
52
+ "agent_history_id": "hist-1",
53
+ },
54
+ )
55
+
56
+ def test_create_case_with_rubrics_sends_atomic_evaluator_assignments(self) -> None:
57
+ client = FakeClient()
58
+
59
+ eval_mod.create_case_with_rubrics( # type: ignore[arg-type]
60
+ client,
61
+ agent_id="agent-1",
62
+ input="Question",
63
+ rubrics_by_evaluator={"eval-1": "Must pass", "eval-2": "Must cite"},
64
+ )
65
+
66
+ self.assertEqual(len(client.calls), 1)
67
+ self.assertEqual(client.calls[0][0:2], ("POST", "/external/eval/cases"))
68
+ self.assertEqual(
69
+ client.calls[0][2]["json"]["evaluators"],
70
+ [
71
+ {"evaluator_id": "eval-1", "rubric": "Must pass"},
72
+ {"evaluator_id": "eval-2", "rubric": "Must cite"},
73
+ ],
74
+ )
75
+
76
+
77
+ class EvalPairPlannerTests(unittest.TestCase):
78
+ def test_plans_only_assigned_pairs_for_specific_evaluator(self) -> None:
79
+ rows = [
80
+ {"case_id": "case-1", "evaluators": [{"evaluator_id": "eval-1", "rubric": "A"}]},
81
+ {"case_id": "case-2", "evaluators": [{"evaluator_id": "eval-2", "rubric": "B"}]},
82
+ ]
83
+
84
+ pairs, skipped = _planned_eval_pairs(
85
+ case_ids=["case-1", "case-2"],
86
+ assigned_by_case=_assigned_evaluators_by_case(rows),
87
+ requested_evaluator_ids=["eval-1"],
88
+ )
89
+
90
+ self.assertEqual(pairs, [{"case_id": "case-1", "evaluator_id": "eval-1"}])
91
+ self.assertEqual(
92
+ skipped,
93
+ [{"case_id": "case-2", "evaluator_id": "eval-1", "reason": "not_assigned"}],
94
+ )
95
+
96
+ def test_omitted_evaluator_runs_all_assigned_pairs(self) -> None:
97
+ rows = [
98
+ {
99
+ "case_id": "case-1",
100
+ "evaluators": [
101
+ {"evaluator_id": "eval-1", "rubric": "A"},
102
+ {"evaluator_id": "eval-2", "rubric": "B"},
103
+ ],
104
+ },
105
+ {"case_id": "case-2", "evaluators": []},
106
+ ]
107
+
108
+ pairs, skipped = _planned_eval_pairs(
109
+ case_ids=["case-1", "case-2"],
110
+ assigned_by_case=_assigned_evaluators_by_case(rows),
111
+ requested_evaluator_ids=None,
112
+ )
113
+
114
+ self.assertEqual(
115
+ pairs,
116
+ [
117
+ {"case_id": "case-1", "evaluator_id": "eval-1"},
118
+ {"case_id": "case-1", "evaluator_id": "eval-2"},
119
+ ],
120
+ )
121
+ self.assertEqual(skipped, [])
122
+
123
+
124
+ if __name__ == "__main__":
125
+ unittest.main()
@@ -26,7 +26,7 @@ wheels = [
26
26
 
27
27
  [[package]]
28
28
  name = "codeer-cli"
29
- version = "0.1.6"
29
+ version = "0.1.7"
30
30
  source = { editable = "." }
31
31
  dependencies = [
32
32
  { name = "httpx" },
File without changes