Skip to content

Commit 58f0cfe

Browse files
committed
Add agent-focused OPS UX improvements and deterministic coverage
1 parent baa57bc commit 58f0cfe

File tree

16 files changed

+2411
-105
lines changed

16 files changed

+2411
-105
lines changed

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,8 @@ epo family get EP.1000000.A1
6161
# Full text search
6262
epo pub search --query "applicant=IBM" --range 1-25
6363

64-
# Search all pages sorted by newest publication date
65-
epo pub search --query "applicant=\"SAP SE\" and pd>=2024" --all --sort pub-date-desc --flat
64+
# Search all pages sorted by newest publication date (recommended date syntax)
65+
epo pub search --query "applicant=\"SAP SE\" and pd within \"20250101 20260304\"" --all --sort pub-date-desc --flat
6666

6767
# Agent-friendly table shortcut
6868
epo pub search --query "applicant=IBM" --all --table
@@ -79,6 +79,9 @@ epo register get EP99203729
7979
# Number format conversion
8080
epo number convert EP.1000000.A1 --ref-type publication --from-format docdb --to-format epodoc
8181

82+
# Git Bash / MSYS raw path call on Windows
83+
MSYS_NO_PATHCONV=1 epo raw get "/published-data/publication/docdb/EP.1000000.A1/claims" -f json -q
84+
8285
# Show saved credential status
8386
epo config show
8487

docs/api-reference/services.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,8 @@ Content-Type: text/plain
247247
q=pa%3DIBM
248248
```
249249

250+
Using combined constituents is generally more quota-efficient than separate calls because OPS returns dossier slices in one request envelope.
251+
250252
**Pagination:** default 1–25, max 100 via `Range` header.
251253

252254
---

docs/guides/rate-limits.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,3 +162,9 @@ Authorization: Bearer <token>
162162
- Timestamps in Unix time (ms)
163163

164164
Updates within 10 minutes of each hour.
165+
166+
CLI note:
167+
- `epo usage stats --human-dates` adds readable date fields.
168+
- `epo usage today` / `epo usage week` provide quick range shortcuts.
169+
- `epo usage quota` shows only current quota + throttle metadata.
170+
- Quota counters can lag behind message counts due to OPS aggregation cadence.

docs/todo.md

Lines changed: 60 additions & 59 deletions
Large diffs are not rendered by default.
Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
package cli
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func TestSplitProjectionPathWithArrayIndices(t *testing.T) {
9+
got := splitProjectionPath("results.environments[0].dimensions[1].metrics")
10+
want := []string{"results", "environments", "0", "dimensions", "1", "metrics"}
11+
if len(got) != len(want) {
12+
t.Fatalf("unexpected segment count: got=%d want=%d (%v)", len(got), len(want), got)
13+
}
14+
for i := range want {
15+
if got[i] != want[i] {
16+
t.Fatalf("segment %d mismatch: got=%q want=%q", i, got[i], want[i])
17+
}
18+
}
19+
}
20+
21+
func TestProjectBatchResultsByFields(t *testing.T) {
22+
batch := []any{
23+
map[string]any{
24+
"input": "q1",
25+
"ok": true,
26+
"results": []any{
27+
map[string]any{"reference": "EP1000000A1", "title": "One"},
28+
map[string]any{"reference": "EP1000001A1", "title": "Two"},
29+
},
30+
},
31+
}
32+
projected, ok := projectBatchResultsByFields(batch, []string{"reference"})
33+
if !ok {
34+
t.Fatal("expected batch projection to match")
35+
}
36+
rows := projected.([]map[string]any)
37+
if len(rows) != 1 {
38+
t.Fatalf("expected one batch row, got %d", len(rows))
39+
}
40+
results, ok := rows[0]["results"].([]map[string]any)
41+
if !ok || len(results) != 2 {
42+
t.Fatalf("expected projected inner results, got %#v", rows[0]["results"])
43+
}
44+
if results[0]["reference"] != "EP1000000A1" {
45+
t.Fatalf("unexpected projected value: %#v", results[0])
46+
}
47+
}
48+
49+
func TestProjectEnvelopeIfRequested(t *testing.T) {
50+
prev := flagPick
51+
flagPick = "quota.hourUsed,results.environments[0].dimensions[0].metrics[0].value"
52+
defer func() { flagPick = prev }()
53+
54+
env := successEnvelope{
55+
Quota: map[string]any{"hourUsed": 7},
56+
Results: map[string]any{
57+
"environments": []any{
58+
map[string]any{
59+
"dimensions": []any{
60+
map[string]any{
61+
"metrics": []any{
62+
map[string]any{"value": 123},
63+
},
64+
},
65+
},
66+
},
67+
},
68+
},
69+
}
70+
projected, ok := projectEnvelopeIfRequested(env)
71+
if !ok {
72+
t.Fatal("expected envelope projection to succeed")
73+
}
74+
row := projected.(map[string]any)
75+
if row["quota.hourUsed"] != 7 {
76+
t.Fatalf("unexpected quota projection: %#v", row)
77+
}
78+
if row["results.environments[0].dimensions[0].metrics[0].value"] != 123 {
79+
t.Fatalf("unexpected nested projection: %#v", row)
80+
}
81+
}
82+
83+
func TestValidateCQLDateSyntax(t *testing.T) {
84+
if err := validateCQLDateSyntax(`pa=IBM and pd within "20250101 20251231"`); err != nil {
85+
t.Fatalf("unexpected validation failure: %v", err)
86+
}
87+
if err := validateCQLDateSyntax("pa=IBM and pd>=20250101"); err == nil {
88+
t.Fatal("expected invalid pd>=YYYYMMDD syntax error")
89+
}
90+
}
91+
92+
func TestResolvePubInputFormatAndClaimsRouting(t *testing.T) {
93+
if got := resolvePubInputFormat("EP.1000000.A1", "auto"); got != "docdb" {
94+
t.Fatalf("unexpected auto format: %s", got)
95+
}
96+
if got := resolvePubInputFormat("EP1000000A1", "auto"); got != "epodoc" {
97+
t.Fatalf("unexpected auto format for epodoc reference: %s", got)
98+
}
99+
100+
format, reference := routeClaimsAndDescriptionInput("epodoc", "EP1000000A1", "claims")
101+
if format != "docdb" || reference != "EP.1000000.A1" {
102+
t.Fatalf("unexpected claims routing: %s %s", format, reference)
103+
}
104+
}
105+
106+
func TestNormalizeImageFetchPath(t *testing.T) {
107+
got := normalizeImageFetchPath("https://ops.epo.org/rest-services/published-data/images/EP/1000000/A1/fullimage")
108+
if got != "EP/1000000/A1/fullimage" {
109+
t.Fatalf("unexpected normalized link path: %s", got)
110+
}
111+
}
112+
113+
func TestWithImageFetchPaths(t *testing.T) {
114+
input := map[string]any{
115+
"document-instance": map[string]any{
116+
"@link": "published-data/images/EP/1000000/A1/fullimage",
117+
},
118+
}
119+
out := withImageFetchPaths(input).(map[string]any)
120+
di := asAnyMap(out["document-instance"])
121+
if di["fetch_path"] != "EP/1000000/A1/fullimage" {
122+
t.Fatalf("expected fetch_path, got %#v", di)
123+
}
124+
}
125+
126+
func TestWithFulltextSuggestions(t *testing.T) {
127+
body := []byte("<root><kind>A1</kind><kind>B1</kind></root>")
128+
out := withFulltextSuggestions("/published-data/publication/epodoc/EP1000000/fulltext", body, map[string]any{}).(map[string]any)
129+
commands, ok := out["suggested_retrieval_commands"].([]string)
130+
if !ok || len(commands) == 0 {
131+
t.Fatalf("expected suggested retrieval commands, got %#v", out["suggested_retrieval_commands"])
132+
}
133+
if !strings.Contains(commands[0], "epo pub claims") {
134+
t.Fatalf("unexpected suggestion command: %s", commands[0])
135+
}
136+
}
137+
138+
func TestStripMixedLayoutNodes(t *testing.T) {
139+
input := map[string]any{
140+
"reg:event-data": map[string]any{
141+
"mixed.layout": []any{"one", "two"},
142+
"kept": true,
143+
},
144+
}
145+
out := stripMixedLayoutNodes(input).(map[string]any)
146+
eventData := asAnyMap(out["reg:event-data"])
147+
if _, exists := eventData["mixed.layout"]; exists {
148+
t.Fatalf("mixed.layout should be stripped: %#v", eventData)
149+
}
150+
}
151+
152+
func TestDetectNumberFormat(t *testing.T) {
153+
if got := detectNumberFormat("EP.1000000.A1"); got != "docdb" {
154+
t.Fatalf("unexpected format: %s", got)
155+
}
156+
if got := detectNumberFormat("EP1000000A1"); got != "epodoc" {
157+
t.Fatalf("unexpected format: %s", got)
158+
}
159+
if got := detectNumberFormat("US.(08/921,321).A.19970829"); got != "original" {
160+
t.Fatalf("unexpected format: %s", got)
161+
}
162+
}
163+
164+
func TestFlattenLegalEvents(t *testing.T) {
165+
input := map[string]any{
166+
"events": []any{
167+
map[string]any{
168+
"L001EP": "CODE",
169+
"L002EP": "Description",
170+
"L003EP": "DE",
171+
"L007EP": "20260101",
172+
},
173+
},
174+
}
175+
rows := flattenLegalEvents(input)
176+
if len(rows) != 1 {
177+
t.Fatalf("expected one legal row, got %d", len(rows))
178+
}
179+
if rows[0]["code"] != "CODE" || rows[0]["country"] != "DE" {
180+
t.Fatalf("unexpected legal row: %#v", rows[0])
181+
}
182+
}
183+
184+
func TestSummarizeRegisterPayload(t *testing.T) {
185+
input := map[string]any{
186+
"ops:world-patent-data": map[string]any{
187+
"ops:register-search": map[string]any{
188+
"reg:register-documents": map[string]any{
189+
"reg:register-document": map[string]any{
190+
"reg:bibliographic-data": map[string]any{
191+
"reg:application-reference": map[string]any{
192+
"reg:document-id": map[string]any{
193+
"reg:country": map[string]any{"$": "EP"},
194+
"reg:doc-number": map[string]any{"$": "123456"},
195+
},
196+
},
197+
"reg:publication-reference": map[string]any{
198+
"reg:document-id": map[string]any{
199+
"reg:country": map[string]any{"$": "WO"},
200+
"reg:doc-number": map[string]any{"$": "20260001"},
201+
"reg:date": map[string]any{"$": "20260101"},
202+
},
203+
},
204+
},
205+
"reg:ep-patent-statuses": map[string]any{
206+
"reg:ep-patent-status": map[string]any{"$": "Pending"},
207+
},
208+
},
209+
},
210+
},
211+
"reg:designated-state": "DE",
212+
"reg:lapsed-in-country": "FR",
213+
},
214+
}
215+
summary := summarizeRegisterPayload(input)
216+
if summary["status"] != "Pending" {
217+
t.Fatalf("unexpected register status: %#v", summary)
218+
}
219+
if summary["application"] != "EP123456" {
220+
t.Fatalf("unexpected application ref: %#v", summary)
221+
}
222+
}
223+
224+
func TestExtractUsageRowsWithMetrics(t *testing.T) {
225+
input := map[string]any{
226+
"environments": []any{
227+
map[string]any{
228+
"name": "prod",
229+
"dimensions": []any{
230+
map[string]any{
231+
"metrics": []any{
232+
map[string]any{
233+
"name": "message_count",
234+
"points": []any{
235+
map[string]any{"date": "20260304", "value": 50},
236+
},
237+
},
238+
map[string]any{
239+
"name": "total_response_size",
240+
"points": []any{
241+
map[string]any{"date": "20260304", "value": 4096},
242+
},
243+
},
244+
},
245+
},
246+
},
247+
},
248+
},
249+
}
250+
251+
rows, ok := extractUsageRows(input)
252+
if !ok || len(rows) != 1 {
253+
t.Fatalf("expected flattened usage rows, got %#v", rows)
254+
}
255+
if rows[0]["message_count"] != "50" || rows[0]["total_response_size"] != "4096" {
256+
t.Fatalf("unexpected usage metrics row: %#v", rows[0])
257+
}
258+
}
259+
260+
func TestWithUsageHumanDates(t *testing.T) {
261+
input := map[string]any{"date": "20260304"}
262+
out := withUsageHumanDates(input).(map[string]any)
263+
if out["date_human"] == "" {
264+
t.Fatalf("expected human date enrichment: %#v", out)
265+
}
266+
}
267+
268+
func TestNormalizeCPCPayload(t *testing.T) {
269+
searchXML := []byte(`
270+
<root>
271+
<classification-symbol>H04L45/00</classification-symbol>
272+
<class-title>Routing</class-title>
273+
<score>15.23</score>
274+
</root>
275+
`)
276+
searchRows := normalizeCPCPayload("search", "", "", "", searchXML)
277+
if len(searchRows) != 1 {
278+
t.Fatalf("expected one search row, got %d", len(searchRows))
279+
}
280+
if searchRows[0]["symbol"] != "H04L45/00" {
281+
t.Fatalf("unexpected search row: %#v", searchRows[0])
282+
}
283+
284+
mapXML := []byte(`<root><classification-symbol>H04L45/00</classification-symbol><classification-symbol>H04L45/10</classification-symbol></root>`)
285+
mapRows := normalizeCPCPayload("map", "H04L45/00", "cpc", "ipc", mapXML)
286+
if len(mapRows) == 0 {
287+
t.Fatal("expected mapping rows")
288+
}
289+
if mapRows[0]["fromScheme"] != "CPC" || mapRows[0]["toScheme"] != "IPC" {
290+
t.Fatalf("unexpected map row: %#v", mapRows[0])
291+
}
292+
}

0 commit comments

Comments
 (0)