forked from diegoecab/oci-deal-accelerator
Root cause observed in a real Codex session: the agent burned ~3min
on `make venv` + 4 sequential `make diagram-lookup` queries trying
to find a non-existent ref-arch (OCI PostgreSQL accessed FROM GCP
via Cross-Cloud Interconnect). The catalog has 123 entries; if two
honest queries can't surface a topology, none exists — but nothing
in the skill said "stop." Three coordinated fixes:
1) Makefile: `make venv` is now idempotent.
Marker file `.venv/.deps-installed` keyed off `requirements.txt`
mtime — the second `make venv` in a row is a 26ms no-op (was
1m53s on a Codex sandbox, paid every turn). Force a rebuild with
`rm .venv/.deps-installed`.
2) kb/architecture-center/catalog.yaml: new `known_gaps` block.
Surfaced by the lookup tool as a top-of-output banner whenever
the (synonym-expanded) query tokens match any one of a gap's
`triggers` token sets. Two gaps seeded from real engagements:
- gcp-to-oci-native-services — OCI PostgreSQL/OKE/Cache/etc.
reached FROM GCP via Cross-Cloud Interconnect (the
Database@Google Cloud entries are the OPPOSITE direction).
- newer-oci-services-without-icon — OCI Cache (Redis/Valkey)
and OCI PostgreSQL post-v24.2-toolkit; no ref-arch, no
toolkit icon, fall back to generic stencil + explicit label.
Each gap's `notice:` is a copy-paste recommended composition so
the agent doesn't have to invent a strategy.
3) tools/archcenter_pattern_lookup.py: detects + surfaces gaps.
`lookup()` now returns `{"matches": ..., "gaps": ...}`. The
text printer emits a "⚠ KNOWN GAP — <id>" banner with the
notice block ABOVE the score-based top-K, so the agent sees
the stop-and-compose signal before ever scrolling to results.
YAML output mode mirrors the same shape under `gaps:`.
4) SKILL.md (option 2 step 1) + Codex copy via sync-skill.
New explicit rule: "Lookup budget: max 2 queries — never loop."
Documents the gap banner, the closest-3-then-ask fallback, and
the prohibition against grasping at a 3rd refinement.
Verified manually:
- GCP+postgresql query → gap fires
- "redis ha multi-az" → newer-OCI gap fires
- "exadata cross region data guard" → no false-positive gap
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
490 lines
19 KiB
Python
490 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
archcenter_pattern_lookup — find Oracle Architecture Center patterns
|
|
that match a topology prompt, so spec authors can base their diagram
|
|
on Oracle's canonical conventions instead of inventing layout.
|
|
|
|
Usage:
|
|
python tools/archcenter_pattern_lookup.py "aws fastconnect exacs"
|
|
python tools/archcenter_pattern_lookup.py "data guard cross region adb"
|
|
python tools/archcenter_pattern_lookup.py "oke three tier"
|
|
python tools/archcenter_pattern_lookup.py --top 10 "azure"
|
|
|
|
Sources:
|
|
kb/architecture-center/catalog.yaml — 123 OCI Architecture
|
|
Center entries with
|
|
services + tags + URL.
|
|
kb/diagram/reference-patterns.yaml — visual conventions
|
|
(FastConnect badge, DRG
|
|
position, multi-cloud
|
|
styling, …)
|
|
|
|
Output for each match:
|
|
- title + URL
|
|
- tags + services
|
|
- cached_assets (if we have the .drawio locally)
|
|
- visual_conventions (if linked from reference-patterns.yaml)
|
|
- score
|
|
|
|
Higher score = better match. Use the top result's URL/cached drawio
|
|
as the basis for your absolute_layout authoring.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
CATALOG = PROJECT_ROOT / "kb" / "architecture-center" / "catalog.yaml"
|
|
PATTERNS = PROJECT_ROOT / "kb" / "diagram" / "reference-patterns.yaml"
|
|
SYNONYMS = PROJECT_ROOT / "kb" / "architecture-center" / "synonyms.yaml"
|
|
CACHE_DIR = PROJECT_ROOT / "kb" / "diagram" / "assets" / "archcenter-refs"
|
|
|
|
|
|
def _tokens(text: str) -> set[str]:
|
|
text = (text or "").lower()
|
|
return set(re.findall(r"[a-z0-9]+", text))
|
|
|
|
|
|
_SYNONYM_INDEX: dict[str, set[str]] | None = None
|
|
|
|
|
|
def _load_synonym_index() -> dict[str, set[str]]:
|
|
"""Build alias-token → expansion-token-set map. Loaded once.
|
|
|
|
Each canonical phrase contributes a token set; every alias maps to
|
|
that same set, plus the alias's own tokens. Multi-word aliases
|
|
expand all their constituent tokens too, so 'lb' → {load, balancer}.
|
|
"""
|
|
global _SYNONYM_INDEX
|
|
if _SYNONYM_INDEX is not None:
|
|
return _SYNONYM_INDEX
|
|
index: dict[str, set[str]] = {}
|
|
if not SYNONYMS.exists():
|
|
_SYNONYM_INDEX = index
|
|
return index
|
|
try:
|
|
doc = yaml.safe_load(SYNONYMS.read_text(encoding="utf-8")) or {}
|
|
except yaml.YAMLError:
|
|
_SYNONYM_INDEX = index
|
|
return index
|
|
for entry in doc.get("synonyms", []) or []:
|
|
canonical = entry.get("canonical", "")
|
|
canonical_tokens = _tokens(canonical)
|
|
if not canonical_tokens:
|
|
continue
|
|
# Canonical tokens map to themselves (so they expand into siblings too).
|
|
for ct in canonical_tokens:
|
|
index.setdefault(ct, set()).update(canonical_tokens)
|
|
for alias in entry.get("aliases", []) or []:
|
|
alias_tokens = _tokens(alias)
|
|
for at in alias_tokens:
|
|
index.setdefault(at, set()).update(canonical_tokens | alias_tokens)
|
|
_SYNONYM_INDEX = index
|
|
return index
|
|
|
|
|
|
def _expand_query_tokens(tokens: set[str]) -> set[str]:
|
|
"""Apply the synonym index to a query token set."""
|
|
index = _load_synonym_index()
|
|
if not index:
|
|
return tokens
|
|
expanded = set(tokens)
|
|
for tok in tokens:
|
|
expanded.update(index.get(tok, set()))
|
|
return expanded
|
|
|
|
|
|
def _llm_rewrite_query(query: str) -> str:
|
|
"""Rewrite ``query`` into canonical OCI/Oracle terminology via the
|
|
Anthropic API.
|
|
|
|
Opt-in: requires ``ANTHROPIC_API_KEY`` in the environment. Returns
|
|
the original query unchanged on any error or when the key is
|
|
missing — never fails the lookup. The synonym table covers the
|
|
common abbreviations; this hook is for natural-language queries
|
|
where rule-based expansion alone misses the intent.
|
|
"""
|
|
api_key = os.environ.get("ANTHROPIC_API_KEY")
|
|
if not api_key:
|
|
print("[lookup] --llm-rewrite skipped: ANTHROPIC_API_KEY not set",
|
|
file=sys.stderr)
|
|
return query
|
|
model = os.environ.get("ANTHROPIC_MODEL", "claude-haiku-4-5-20251001")
|
|
payload = {
|
|
"model": model,
|
|
"max_tokens": 120,
|
|
"system": (
|
|
"Rewrite the user's free-text architecture query into a "
|
|
"concise list of canonical OCI and cloud terms. Expand "
|
|
"abbreviations (LB → load balancer, ADG → active data "
|
|
"guard, AD → availability domain, etc.). Do NOT add "
|
|
"topology elements the user did not request. Output ONLY "
|
|
"the rewritten query as a single space-separated phrase, "
|
|
"no preface, no quotes."
|
|
),
|
|
"messages": [{"role": "user", "content": query}],
|
|
}
|
|
req = urllib.request.Request(
|
|
"https://api.anthropic.com/v1/messages",
|
|
data=json.dumps(payload).encode("utf-8"),
|
|
headers={
|
|
"content-type": "application/json",
|
|
"x-api-key": api_key,
|
|
"anthropic-version": "2023-06-01",
|
|
},
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
body = json.loads(resp.read().decode("utf-8"))
|
|
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, ValueError) as exc:
|
|
print(f"[lookup] --llm-rewrite failed: {exc}; using original query",
|
|
file=sys.stderr)
|
|
return query
|
|
blocks = body.get("content") or []
|
|
text = " ".join(b.get("text", "") for b in blocks if b.get("type") == "text").strip()
|
|
if not text:
|
|
return query
|
|
print(f"[lookup] llm-rewrote: {query!r} → {text!r}", file=sys.stderr)
|
|
return text
|
|
|
|
|
|
def _description_text(entry: dict) -> str:
|
|
"""Read the cached `_description.md` body if present.
|
|
|
|
Populated by ``tools/archcenter_description_fetcher.py``. Lets the
|
|
scorer match against the full page text (rationale, components,
|
|
recommendations) — much higher recall than the 1-line catalog
|
|
summary alone.
|
|
"""
|
|
if not CACHE_DIR.exists():
|
|
return ""
|
|
url = entry.get("url", "") or ""
|
|
m = re.search(r"/solutions/([^/]+)/", url)
|
|
if not m:
|
|
return ""
|
|
description_path = CACHE_DIR / m.group(1) / "_description.md"
|
|
if not description_path.exists():
|
|
return ""
|
|
try:
|
|
return description_path.read_text(encoding="utf-8")
|
|
except OSError:
|
|
return ""
|
|
|
|
|
|
def _score(query_tokens: set[str], entry: dict, description: str = "") -> float:
|
|
"""Token-overlap scoring with weighted fields:
|
|
title: 3.0
|
|
tags: 2.0
|
|
services: 1.5
|
|
summary: 1.0
|
|
description: 0.6 (per match, capped at 8 unique tokens)
|
|
"""
|
|
score = 0.0
|
|
title_tokens = _tokens(entry.get("title", ""))
|
|
tag_tokens = {t.lower() for t in entry.get("tags", [])}
|
|
svc_tokens = {s.lower() for s in entry.get("services", [])}
|
|
summary_tokens = _tokens(entry.get("summary", ""))
|
|
score += 3.0 * len(query_tokens & title_tokens)
|
|
score += 2.0 * len(query_tokens & tag_tokens)
|
|
score += 1.5 * len(query_tokens & svc_tokens)
|
|
score += 1.0 * len(query_tokens & summary_tokens)
|
|
if description:
|
|
desc_tokens = _tokens(description)
|
|
# Cap at 8 to avoid long descriptions dominating the score.
|
|
score += 0.6 * min(8, len(query_tokens & desc_tokens))
|
|
return score
|
|
|
|
|
|
_CACHE_DIR_INDEX: dict[str, dict[str, str]] | None = None
|
|
CACHE_INDEX_FILE = PROJECT_ROOT / "kb" / "diagram" / "assets" / "archcenter-refs-index.json"
|
|
|
|
|
|
def _build_cache_dir_index() -> dict[str, dict[str, str]]:
|
|
"""Scan of CACHE_DIR. Returns {folder_name: {ext: relpath}}.
|
|
|
|
The previous implementation called ``iterdir()`` + ``rglob`` for
|
|
EVERY catalog entry the scorer touched (123 times) — on WSL2 this
|
|
pushed a single lookup to 75+ seconds. Now:
|
|
1. In-memory cache for the duration of the process.
|
|
2. Persisted JSON index next to the assets, refreshed only when
|
|
CACHE_DIR's mtime changes — so a fresh process reads the
|
|
index in milliseconds instead of walking 113 subdirs through
|
|
WSL2's /mnt/c (~16 s on a typical laptop).
|
|
"""
|
|
global _CACHE_DIR_INDEX
|
|
if _CACHE_DIR_INDEX is not None:
|
|
return _CACHE_DIR_INDEX
|
|
if not CACHE_DIR.exists():
|
|
_CACHE_DIR_INDEX = {}
|
|
return _CACHE_DIR_INDEX
|
|
|
|
cache_mtime = CACHE_DIR.stat().st_mtime
|
|
if CACHE_INDEX_FILE.exists():
|
|
try:
|
|
payload = json.loads(CACHE_INDEX_FILE.read_text(encoding="utf-8"))
|
|
if payload.get("cache_dir_mtime") == cache_mtime:
|
|
_CACHE_DIR_INDEX = payload.get("entries") or {}
|
|
return _CACHE_DIR_INDEX
|
|
except (OSError, ValueError):
|
|
pass # fall through and rebuild
|
|
|
|
index: dict[str, dict[str, str]] = {}
|
|
for sub in CACHE_DIR.iterdir():
|
|
if not sub.is_dir():
|
|
continue
|
|
bucket: dict[str, str] = {}
|
|
for ext in ("drawio", "png", "svg"):
|
|
for path in sub.rglob(f"*.{ext}"):
|
|
bucket[ext] = str(path.relative_to(PROJECT_ROOT))
|
|
break # only the first hit per ext is enough
|
|
# Surface the auto-extracted absolute_layout template if present.
|
|
# Agents read this YAML as the spec scaffold (Oracle's canonical
|
|
# geometry) — generated by tools/archcenter_drawio_to_template.py.
|
|
template = sub / "_template.yaml"
|
|
if template.is_file():
|
|
bucket["yaml"] = str(template.relative_to(PROJECT_ROOT))
|
|
if bucket:
|
|
index[sub.name] = bucket
|
|
|
|
try:
|
|
CACHE_INDEX_FILE.write_text(
|
|
json.dumps({"cache_dir_mtime": cache_mtime, "entries": index},
|
|
indent=2),
|
|
encoding="utf-8",
|
|
)
|
|
except OSError:
|
|
pass # index is still useful in-memory even if we can't persist
|
|
|
|
_CACHE_DIR_INDEX = index
|
|
return index
|
|
|
|
|
|
def _cached_assets(entry: dict) -> dict:
|
|
"""Find .drawio / .png / .svg / _template.yaml if a matching folder
|
|
is cached locally. Served from the one-shot in-memory index built
|
|
by ``_build_cache_dir_index``.
|
|
|
|
The ``yaml`` key (when present) points to ``_template.yaml`` — the
|
|
auto-extracted absolute_layout scaffold from
|
|
``tools/archcenter_drawio_to_template.py``. Agents should copy this
|
|
YAML as the starting point for a new spec instead of falling back
|
|
to ``examples/`` (which is forbidden as a geometry source).
|
|
"""
|
|
cache_index = _build_cache_dir_index()
|
|
if not cache_index:
|
|
return {}
|
|
title_slug = re.sub(r"[^a-z0-9]+", "-", entry.get("title", "").lower()).strip("-")
|
|
url_slug = ""
|
|
if entry.get("url"):
|
|
m = re.search(r"/solutions/([^/]+)/", entry["url"])
|
|
if m:
|
|
url_slug = m.group(1)
|
|
for name, bucket in cache_index.items():
|
|
if (url_slug and (url_slug == name or url_slug in name)) or (name in title_slug):
|
|
return bucket
|
|
return {}
|
|
|
|
|
|
_PATTERNS_BY_URL: dict[str, list[str]] | None = None
|
|
|
|
|
|
def _build_patterns_index() -> dict[str, list[str]]:
|
|
"""Load reference-patterns.yaml ONCE and index by entry URL.
|
|
Was being reloaded for every match (125x per lookup = 5s wasted)."""
|
|
global _PATTERNS_BY_URL
|
|
if _PATTERNS_BY_URL is not None:
|
|
return _PATTERNS_BY_URL
|
|
index: dict[str, list[str]] = {}
|
|
if not PATTERNS.exists():
|
|
_PATTERNS_BY_URL = index
|
|
return index
|
|
try:
|
|
patterns_doc = yaml.safe_load(PATTERNS.read_text(encoding="utf-8")) or {}
|
|
except yaml.YAMLError:
|
|
_PATTERNS_BY_URL = index
|
|
return index
|
|
for pname, pdata in (patterns_doc.get("patterns") or {}).items():
|
|
url = (pdata or {}).get("source")
|
|
if url:
|
|
index.setdefault(url, []).append(pname)
|
|
_PATTERNS_BY_URL = index
|
|
return index
|
|
|
|
|
|
def _patterns_for(entry: dict) -> list[str]:
|
|
"""Surface visual-convention names that apply to this entry."""
|
|
return _build_patterns_index().get(entry.get("url", ""), [])
|
|
|
|
|
|
def _matched_gaps(query_tokens: set[str], gaps: list[dict]) -> list[dict]:
|
|
"""Return every known gap whose triggers match the (expanded) query.
|
|
|
|
A gap fires when ANY one of its `triggers` token sets is a subset of
|
|
`query_tokens` — i.e. AND inside each set, OR across sets. This
|
|
matches the "stop searching, compose instead" intent: if even one
|
|
narrow trigger pair (e.g. `[gcp, postgresql]`) is in the query, the
|
|
catalog has nothing canonical to offer.
|
|
"""
|
|
matched: list[dict] = []
|
|
for gap in gaps or []:
|
|
triggers = gap.get("triggers") or []
|
|
for trigger_set in triggers:
|
|
tokens_required = {t.lower() for t in trigger_set}
|
|
if tokens_required and tokens_required.issubset(query_tokens):
|
|
matched.append(gap)
|
|
break
|
|
return matched
|
|
|
|
|
|
def lookup(query: str, top: int = 5, expand_synonyms: bool = True) -> dict:
|
|
"""Score the catalog against `query` and return the top-K plus any
|
|
known-gap notices the query triggers.
|
|
|
|
Returns a dict shaped as:
|
|
{"matches": [<top-K entry dicts>], "gaps": [<matched gap dicts>]}
|
|
|
|
Callers that only care about matches can read `result["matches"]`.
|
|
"""
|
|
catalog = yaml.safe_load(CATALOG.read_text(encoding="utf-8"))
|
|
entries = catalog.get("entries", [])
|
|
gaps_def = catalog.get("known_gaps", []) or []
|
|
qt = _tokens(query)
|
|
if expand_synonyms:
|
|
qt = _expand_query_tokens(qt)
|
|
matched_gaps = _matched_gaps(qt, gaps_def)
|
|
scored = []
|
|
# Two-pass scoring: cheap fields first (title/tags/services/summary),
|
|
# then enrich the top-K with expensive fields (description text from
|
|
# disk, cached_assets, visual_patterns). Previously _cached_assets
|
|
# ran for every entry whose tokens overlapped at all — 121 of 123
|
|
# entries on a typical query — pushing the lookup to 75s+ on WSL2.
|
|
light: list[dict] = []
|
|
for e in entries:
|
|
s_light = _score(qt, e, description="")
|
|
if s_light <= 0:
|
|
continue
|
|
light.append({"_entry": e, "_score_light": s_light})
|
|
|
|
# Re-rank the candidate set with description text for tie-breaking
|
|
# accuracy, then keep ``top`` * 2 to leave room for description
|
|
# bumps to reorder.
|
|
pool_size = max(top * 3, 10)
|
|
light.sort(key=lambda r: -r["_score_light"])
|
|
candidates = light[:pool_size]
|
|
for c in candidates:
|
|
e = c["_entry"]
|
|
description = _description_text(e)
|
|
c["_description"] = description
|
|
c["_score_full"] = _score(qt, e, description=description)
|
|
candidates.sort(key=lambda r: -r["_score_full"])
|
|
|
|
for c in candidates[:top]:
|
|
e = c["_entry"]
|
|
scored.append({
|
|
"score": round(c["_score_full"], 1),
|
|
"title": e.get("title"),
|
|
"url": e.get("url"),
|
|
"tags": e.get("tags", []),
|
|
"services": e.get("services", []),
|
|
"summary": (e.get("summary", "") or "").strip().split("\n")[0],
|
|
"cached_assets": _cached_assets(e),
|
|
"visual_patterns": _patterns_for(e),
|
|
"has_description": bool(c.get("_description")),
|
|
})
|
|
return {"matches": scored, "gaps": matched_gaps}
|
|
|
|
|
|
def _print_results(query: str, payload: dict) -> None:
|
|
gaps = payload.get("gaps") or []
|
|
results = payload.get("matches") or []
|
|
for gap in gaps:
|
|
notice = (gap.get("notice") or "").rstrip()
|
|
gap_id = gap.get("id", "unknown")
|
|
print(f"⚠ KNOWN GAP — {gap_id}")
|
|
for line in notice.splitlines():
|
|
print(f" {line}" if line else "")
|
|
print()
|
|
if not results:
|
|
print(f"No matches for: {query}")
|
|
return
|
|
print(f"Top {len(results)} matches for: {query!r}\n")
|
|
for i, r in enumerate(results, 1):
|
|
print(f"{i}. [{r['score']}] {r['title']}")
|
|
print(f" URL: {r['url']}")
|
|
print(f" tags: {', '.join(r['tags'])}")
|
|
if r["services"]:
|
|
print(f" services: {', '.join(r['services'])}")
|
|
if r["cached_assets"]:
|
|
print(f" cached: {', '.join(f'{k}={v}' for k, v in r['cached_assets'].items())}")
|
|
if r["visual_patterns"]:
|
|
print(f" visual_patterns: {', '.join(r['visual_patterns'])}")
|
|
if r.get("has_description"):
|
|
print(f" has cached _description.md")
|
|
print()
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("query", nargs="*",
|
|
help="Free-text topology prompt (e.g. 'aws fastconnect exacs').")
|
|
parser.add_argument("--queries", action="append", default=[],
|
|
help="Run multiple sub-queries and report top-K for each. "
|
|
"Use when the desired architecture combines patterns "
|
|
"no single Oracle ref-arch covers (e.g. "
|
|
"--queries 'mysql heatwave' --queries 'load balancer ha').")
|
|
parser.add_argument("--top", type=int, default=5,
|
|
help="Number of matches to return per query (default 5).")
|
|
parser.add_argument("--format", choices=("text", "yaml"), default="text")
|
|
parser.add_argument("--no-synonyms", action="store_true",
|
|
help="Disable query-token expansion via "
|
|
"kb/architecture-center/synonyms.yaml.")
|
|
parser.add_argument("--llm-rewrite", action="store_true",
|
|
help="Rewrite the query via the Anthropic API "
|
|
"before scoring (opt-in; requires "
|
|
"ANTHROPIC_API_KEY). Useful for natural-language "
|
|
"queries the synonym table doesn't cover.")
|
|
parser.add_argument("--rebuild-index", action="store_true",
|
|
help="Force-rebuild the cached archcenter-refs "
|
|
"index (kb/diagram/assets/archcenter-refs-index.json). "
|
|
"Use after a manual KB edit if mtime didn't bump.")
|
|
args = parser.parse_args()
|
|
if args.rebuild_index and CACHE_INDEX_FILE.exists():
|
|
CACHE_INDEX_FILE.unlink()
|
|
print(f"[lookup] removed stale index at {CACHE_INDEX_FILE.relative_to(PROJECT_ROOT)}",
|
|
file=sys.stderr)
|
|
|
|
queries: list[str] = list(args.queries)
|
|
if args.query:
|
|
queries.append(" ".join(args.query))
|
|
if not queries:
|
|
parser.error("Provide a positional query or --queries.")
|
|
|
|
expand = not args.no_synonyms
|
|
if args.llm_rewrite:
|
|
queries = [_llm_rewrite_query(q) for q in queries]
|
|
if args.format == "yaml":
|
|
bundle = {q: lookup(q, top=args.top, expand_synonyms=expand) for q in queries}
|
|
print(yaml.safe_dump(bundle if len(queries) > 1 else bundle[queries[0]],
|
|
sort_keys=False, allow_unicode=True))
|
|
return
|
|
|
|
for idx, q in enumerate(queries):
|
|
if idx > 0:
|
|
print("─" * 60)
|
|
_print_results(q, lookup(q, top=args.top, expand_synonyms=expand))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|