portico-cli 0.1.0__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. portico_cli-0.1.0/PKG-INFO +39 -0
  2. portico_cli-0.1.0/README.md +26 -0
  3. portico_cli-0.1.0/pyproject.toml +51 -0
  4. portico_cli-0.1.0/src/portico/.DS_Store +0 -0
  5. portico_cli-0.1.0/src/portico/__init__.py +1 -0
  6. portico_cli-0.1.0/src/portico/analyzer.py +213 -0
  7. portico_cli-0.1.0/src/portico/cache.py +46 -0
  8. portico_cli-0.1.0/src/portico/cli.py +346 -0
  9. portico_cli-0.1.0/src/portico/config.py +13 -0
  10. portico_cli-0.1.0/src/portico/loaders/__init__.py +0 -0
  11. portico_cli-0.1.0/src/portico/loaders/base.py +45 -0
  12. portico_cli-0.1.0/src/portico/loaders/dir.py +223 -0
  13. portico_cli-0.1.0/src/portico/loaders/file.py +39 -0
  14. portico_cli-0.1.0/src/portico/loaders/repo.py +16 -0
  15. portico_cli-0.1.0/src/portico/loaders/text.py +21 -0
  16. portico_cli-0.1.0/src/portico/loaders/url.py +57 -0
  17. portico_cli-0.1.0/src/portico/providers/__init__.py +0 -0
  18. portico_cli-0.1.0/src/portico/providers/base.py +19 -0
  19. portico_cli-0.1.0/src/portico/providers/claude.py +67 -0
  20. portico_cli-0.1.0/src/portico/providers/gemini.py +6 -0
  21. portico_cli-0.1.0/src/portico/providers/openai.py +46 -0
  22. portico_cli-0.1.0/src/portico/render/__init__.py +25 -0
  23. portico_cli-0.1.0/src/portico/render/apex.py +58 -0
  24. portico_cli-0.1.0/src/portico/render/base.py +23 -0
  25. portico_cli-0.1.0/src/portico/render/color.py +20 -0
  26. portico_cli-0.1.0/src/portico/render/styles/__init__.py +0 -0
  27. portico_cli-0.1.0/src/portico/render/styles/default.py +265 -0
  28. portico_cli-0.1.0/src/portico/schema.py +41 -0
  29. portico_cli-0.1.0/src/portico/summarize.py +101 -0
@@ -0,0 +1,39 @@
1
+ Metadata-Version: 2.3
2
+ Name: portico-cli
3
+ Version: 0.1.0
4
+ Summary: Render any input as a portico – a three-layer ASCII visualization.
5
+ Author: Tomás Ravalli
6
+ Author-email: Tomás Ravalli <tomravalli@gmail.com>
7
+ Requires-Dist: anthropic>=0.100.0
8
+ Requires-Dist: openai>=2.36.0
9
+ Requires-Dist: pydantic>=2.13.4
10
+ Requires-Dist: trafilatura>=2.0.0
11
+ Requires-Python: >=3.12
12
+ Description-Content-Type: text/markdown
13
+
14
+ # portico
15
+
16
+ Render any input -- text, code, URL, repo -- as a **portico**: a three-layer ASCII visualization (`roof` / `pillars` / `base`).
17
+
18
+ Always lowercase. The brand mark is `_ii^`.
19
+
20
+ ## Status
21
+
22
+ Phase 0 (bootstrap) complete. The package skeleton compiles; no functionality is wired up yet. See the phased roadmap for what lands when.
23
+
24
+ ## Develop
25
+
26
+ ```bash
27
+ uv sync # install dev deps
28
+ uv run ruff check . # lint
29
+ uv run pyright # type-check
30
+ uv run pytest # mocked tests; smoke eval auto-skips
31
+ ```
32
+
33
+ The smoke eval (10 live Claude calls + rendered porticoes in `tests/eval/smoke/report/`) needs `ANTHROPIC_API_KEY`. With the key in 1Password CLI, run via:
34
+
35
+ ```bash
36
+ op run --env-file=.env -- arch -arm64 uv run pytest
37
+ ```
38
+
39
+ The `arch -arm64` is required because the 1Password CLI is x86_64 and would otherwise force a Rosetta child.
@@ -0,0 +1,26 @@
1
+ # portico
2
+
3
+ Render any input -- text, code, URL, repo -- as a **portico**: a three-layer ASCII visualization (`roof` / `pillars` / `base`).
4
+
5
+ Always lowercase. The brand mark is `_ii^`.
6
+
7
+ ## Status
8
+
9
+ Phase 0 (bootstrap) complete. The package skeleton compiles; no functionality is wired up yet. See the phased roadmap for what lands when.
10
+
11
+ ## Develop
12
+
13
+ ```bash
14
+ uv sync # install dev deps
15
+ uv run ruff check . # lint
16
+ uv run pyright # type-check
17
+ uv run pytest # mocked tests; smoke eval auto-skips
18
+ ```
19
+
20
+ The smoke eval (10 live Claude calls + rendered porticoes in `tests/eval/smoke/report/`) needs `ANTHROPIC_API_KEY`. With the key in 1Password CLI, run via:
21
+
22
+ ```bash
23
+ op run --env-file=.env -- arch -arm64 uv run pytest
24
+ ```
25
+
26
+ The `arch -arm64` is required because the 1Password CLI is x86_64 and would otherwise force a Rosetta child.
@@ -0,0 +1,51 @@
1
+ [project]
2
+ name = "portico-cli"
3
+ version = "0.1.0"
4
+ description = "Render any input as a portico – a three-layer ASCII visualization."
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Tomás Ravalli", email = "tomravalli@gmail.com" }
8
+ ]
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "anthropic>=0.100.0",
12
+ "openai>=2.36.0",
13
+ "pydantic>=2.13.4",
14
+ "trafilatura>=2.0.0",
15
+ ]
16
+
17
+ [project.scripts]
18
+ portico = "portico.cli:main"
19
+
20
+ [build-system]
21
+ requires = ["uv_build>=0.11.7,<0.12.0"]
22
+ build-backend = "uv_build"
23
+
24
+ [tool.uv.build-backend]
25
+ module-name = "portico"
26
+
27
+ [dependency-groups]
28
+ dev = [
29
+ "pytest>=8.3.0",
30
+ "ruff>=0.7.0",
31
+ "pyright>=1.1.380",
32
+ ]
33
+
34
+ [tool.ruff]
35
+ line-length = 100
36
+ target-version = "py312"
37
+
38
+ [tool.ruff.lint]
39
+ select = ["E", "F", "I", "B", "UP", "SIM", "RUF"]
40
+
41
+ [tool.pyright]
42
+ include = ["src", "tests"]
43
+ pythonVersion = "3.12"
44
+ typeCheckingMode = "standard"
45
+ reportMissingImports = "error"
46
+
47
+ [tool.pytest.ini_options]
48
+ testpaths = ["tests"]
49
+ markers = [
50
+ "live: tests that hit a real LLM provider or network (deselect with '-m \"not live\"')",
51
+ ]
Binary file
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,213 @@
1
+ import json
2
+ import re
3
+ from dataclasses import dataclass
4
+
5
+ from pydantic import ValidationError
6
+
7
+ from portico.providers.base import LLMProvider
8
+ from portico.providers.claude import DEFAULT_MODEL
9
+ from portico.schema import PorticoJSON
10
+
11
+ PROMPT_TEMPLATE = """\
12
+ You are portico. You decompose any input into a three-layer "portico": a roof \
13
+ (the unifying idea), pillars (the load-bearing components), and a base (the foundation \
14
+ everything rests on).
15
+
16
+ Read the input below and emit STRICTLY VALID JSON matching the schema.
17
+
18
+ The JSON is a SINGLE FLAT OBJECT with these top-level keys, in this exact order. None \
19
+ of the keys nest under category headers; do not introduce wrapper objects like \
20
+ "reasoning" or "output".
21
+
22
+ Top-level keys:
23
+
24
+ 1. "input_type": string. What kind of artifact this is (essay, codebase, business plan, ...).
25
+ 2. "type_rationale": string. One sentence on why you classified it that way.
26
+ 3. "decomposition_strategy": string. One sentence on how you'll split it into roof / pillars / base.
27
+ 4. "scratch_outline": array of 3-7 short strings capturing the load-bearing parts before you label.
28
+ 5. "mece_check": string. One sentence: are the pillars mutually exclusive and collectively \
29
+ exhaustive at the same level of abstraction?
30
+ 6. "theme": string. A short free-form label for the input type ("essay", "codebase", ...).
31
+ 7. "title": string. The input's title or a 1-3 word identifier.
32
+ 8. "roof": object {"label": string, "summary": string}. The unifying idea on top.
33
+ 9. "pillars": array of 2-9 objects, each {"label": string, "summary": string}. STRONGLY PREFER 3-5.
34
+ 10. "base": object {"labels": array of 1-4 strings, "summary": string}. The foundation that \
35
+ everything rests on. Use 1 label for a single foundation; add a 2nd/3rd/4th ONLY when each \
36
+ names a distinct foundational course that earns its place. Redundancy is noise.
37
+ 11. "fit_quality": one of "good", "stretched", "forced", "not_applicable".
38
+ 12. "notes_on_fit": string. If not "good", explain why in one sentence.
39
+
40
+ Concrete example of the exact shape (illustrative content; do not copy the values):
41
+
42
+ {
43
+ "input_type": "essay",
44
+ "type_rationale": "Argumentative prose with thesis and supporting reasoning.",
45
+ "decomposition_strategy": "Thesis on top; two arguments as pillars; evidence at base.",
46
+ "scratch_outline": ["Sub-linear trust ...", "Feedback loops ...", "Dunbar ...", "Evidence ..."],
47
+ "mece_check": "Two pillars are non-overlapping causal mechanisms.",
48
+ "theme": "essay",
49
+ "title": "trust at scale",
50
+ "roof": {"label": "Sub-linear Trust", "summary": "Trust scales sub-linearly with size."},
51
+ "pillars": [
52
+ {"label": "Feedback loops", "summary": "Smaller groups close feedback faster."},
53
+ {"label": "Dunbar limit", "summary": "Diffusion of responsibility past ~150."}
54
+ ],
55
+ "base": {"labels": ["Empirical evidence"], "summary": "Observed work on team size and trust."},
56
+ "fit_quality": "good",
57
+ "notes_on_fit": "Essay decomposes cleanly into thesis / arguments / evidence."
58
+ }
59
+
60
+ Notice the roof uses "Sub-linear Trust" (the actual claim from the input), NOT a generic \
61
+ "Thesis". This is the most important rule below.
62
+
63
+ Rules:
64
+
65
+ LABELS (calibrate between two failure modes):
66
+
67
+ Failure mode A -- TOO GENERIC: labels that could apply to any input of the same type \
68
+ ("Hypothesis", "Methodology", "Findings", "Seed Thesis", "Central Claim"). These add no \
69
+ value because they describe the slot, not the contents.
70
+
71
+ Failure mode B -- TOO CRYPTIC: labels so specific they need the input to decode \
72
+ ("5→15→30→60", "Gini-NIAH Tie", "Re-found Each Doubling"). These add no value because the \
73
+ reader has to consult the source to make sense of them.
74
+
75
+ The sweet spot: short noun phrases that are *recognizable from the input* but *readable on \
76
+ their own*. Bias slightly toward the abstract side -- when in doubt between "too specific \
77
+ to read at a glance" and "a bit generic but clear", choose clear.
78
+
79
+ Concrete rules:
80
+ - USE THE INPUT'S OWN VOCABULARY: when the input names a concept ("Dunbar limit", \
81
+ "tragedy of the commons", "loaders"), reuse those terms. Don't invent synonyms.
82
+ - ROOF MUST NOT EQUAL THE TITLE: the banner above the portico already names the input \
83
+ (e.g. "── software README: httpx ──"). The roof must do different work -- name the \
84
+ central claim, the unifying idea, what the input is *about* one rung up from what it \
85
+ *is*. Title `httpx` + roof `Modern HTTP Client` is correct; title `httpx` + roof \
86
+ `httpx` is wrong. EXCEPTION: codebase inputs where the repo/module name is the \
87
+ natural roof and no higher abstraction is available.
88
+ - READABLE AT A GLANCE: every label should make sense to a reader who has not seen the \
89
+ input. If a pillar label is a numerical range, an abbreviation, a coined phrase, or \
90
+ requires recall of a specific passage to decode -- abstract one rung up.
91
+ - NO INVENTED ADJECTIVES: do not modify a noun with an adjective unless that adjective \
92
+ appears in the input or is directly entailed. "Resilient", "Modern", "Robust", \
93
+ "Comprehensive", "Strategic" are red flags -- if the input does not use them, drop them.
94
+ - NO HALLUCINATED CONCEPTS: every label and every summary clause must trace back to \
95
+ language or ideas in the input. Do not extend the author's metaphors or coin new ones.
96
+ - Plain language: when the input uses everyday words, prefer everyday words in the labels. \
97
+ Reach for jargon only if the input itself does.
98
+ - Length: target <= 16 characters per label. Concise noun phrases. Summaries are one sentence.
99
+
100
+ PORTICO:
101
+ - MECE: pillars must NOT overlap; together they must cover the load-bearing parts.
102
+ - Same abstraction level: roof, each pillar, and base operate at one consistent level.
103
+ - Load-bearing test: if you remove a pillar, the input's central purpose collapses.
104
+ - Pillars are not steps: if the input has temporal/sequential structure (recipes, \
105
+ walkthroughs, ordered instructions), the portico is the wrong metaphor -- set \
106
+ fit_quality to "stretched" or worse rather than forcing steps into pillars.
107
+ - Pillar-base separation: pillars and base must not overlap. A core ingredient of the input \
108
+ belongs in the base, not also as a pillar.
109
+ - Pillar count: 2-9 allowed; STRONGLY PREFER 3-5 (Minto's Rule of 3, working memory).
110
+
111
+ FIT (push back when the metaphor does not earn its keep):
112
+ - "good" -- the metaphor lands cleanly.
113
+ - "stretched" -- the metaphor is forced but still informative.
114
+ - "forced" -- you had to invent structure that is not really there.
115
+ - "not_applicable" -- nothing to decompose. Use for: gibberish, random words, flat lists \
116
+ with no organizing principle, single sentences with no internal structure, very short \
117
+ conventional inputs (greetings, idioms), or any content that lacks the kind of \
118
+ structural decomposition the portico models.
119
+ - POETRY ALWAYS REFUSES: lyric and narrative poems do not admit portico decomposition. \
120
+ Poems work through image, rhythm, and meaning-by-accumulation -- they have no \
121
+ load-bearing pillars in the architectural sense. Set fit_quality to "not_applicable" \
122
+ for any poem and explain briefly in notes_on_fit.
123
+ - FLAT LISTS REFUSE: simple lists (shopping lists, word lists, enumerations) lack the \
124
+ structural decomposition the portico models. Set fit_quality to "not_applicable" rather \
125
+ than inventing categorical pillars.
126
+ - When torn between "forced" and "not_applicable", choose "not_applicable" and explain \
127
+ briefly in notes_on_fit. Refusing is a feature; the portico is not for everything.
128
+
129
+ Emit ONLY the JSON object. No markdown fences. No commentary. No preamble.
130
+
131
+ Input to analyze:
132
+
133
+ {input}
134
+ """
135
+
136
+ RETRY_FEEDBACK_HEADER = """\
137
+ Your previous response could not be parsed: {error}
138
+
139
+ Re-emit the JSON object correctly. Output ONLY the JSON object, no fences, no prose.
140
+ Schema and input below remain the same.
141
+
142
+ """
143
+
144
+ _FENCE_RE = re.compile(r"^```(?:json)?\s*(.*?)\s*```\s*$", re.DOTALL)
145
+
146
+
147
+ class AnalyzerError(Exception):
148
+ """Base for analyzer failures."""
149
+
150
+
151
+ class F4MalformedJSON(AnalyzerError):
152
+ """LLM produced unparseable / schema-invalid JSON after the retry budget (F4)."""
153
+
154
+
155
+ @dataclass
156
+ class AnalyzeResult:
157
+ data: PorticoJSON
158
+ raw_response: str
159
+ attempts: int
160
+
161
+
162
+ def build_prompt(text: str) -> str:
163
+ return PROMPT_TEMPLATE.replace("{input}", text)
164
+
165
+
166
+ def build_retry_prompt(base_prompt: str, error: str) -> str:
167
+ header = RETRY_FEEDBACK_HEADER.format(error=error)
168
+ return header + base_prompt
169
+
170
+
171
+ def _strip_fence(s: str) -> str:
172
+ s = s.strip()
173
+ m = _FENCE_RE.match(s)
174
+ if m:
175
+ return m.group(1).strip()
176
+ return s
177
+
178
+
179
+ def _parse_and_validate(raw: str) -> PorticoJSON:
180
+ cleaned = _strip_fence(raw)
181
+ data = json.loads(cleaned)
182
+ return PorticoJSON.model_validate(data)
183
+
184
+
185
+ def analyze(
186
+ text: str,
187
+ *,
188
+ provider: LLMProvider,
189
+ model: str = DEFAULT_MODEL,
190
+ max_retries: int = 2,
191
+ ) -> AnalyzeResult:
192
+ """Send `text` to the LLM, parse + validate JSON, retry on malformed output.
193
+
194
+ Raises F4MalformedJSON after `max_retries` retries (so up to 1 + max_retries calls).
195
+ Provider-side errors (auth, transport) propagate unchanged from the provider.
196
+ """
197
+ base_prompt = build_prompt(text)
198
+ prompt = base_prompt
199
+ last_error: Exception | None = None
200
+ last_raw = ""
201
+
202
+ for attempt in range(max_retries + 1):
203
+ last_raw = provider.generate(prompt, model=model)
204
+ try:
205
+ data = _parse_and_validate(last_raw)
206
+ return AnalyzeResult(data=data, raw_response=last_raw, attempts=attempt + 1)
207
+ except (json.JSONDecodeError, ValidationError) as e:
208
+ last_error = e
209
+ prompt = build_retry_prompt(base_prompt, str(e))
210
+
211
+ raise F4MalformedJSON(
212
+ f"LLM returned unusable output after {max_retries + 1} attempts: {last_error}"
213
+ )
@@ -0,0 +1,46 @@
1
+ """Content-hashed cache for analyzer outputs.
2
+
3
+ The cache is keyed on (text, provider, model) -- the same input through the
4
+ same model returns the same JSON. Per the failure taxonomy, F3 refusals are
5
+ cached (the input genuinely doesn't fit); F1/F2/F4 failures are not (re-run
6
+ may succeed). Caching policy is enforced by the caller, not this module.
7
+ """
8
+
9
+ import hashlib
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ from portico.schema import PorticoJSON
14
+
15
+ DEFAULT_CACHE_DIR = Path.home() / ".cache" / "portico"
16
+
17
+
18
+ def cache_key(text: str, *, provider: str, model: str) -> str:
19
+ payload = f"{provider}|{model}|{text}".encode()
20
+ return hashlib.sha256(payload).hexdigest()
21
+
22
+
23
+ @dataclass
24
+ class Cache:
25
+ root: Path = DEFAULT_CACHE_DIR
26
+
27
+ def _path(self, key: str) -> Path:
28
+ return self.root / f"{key}.json"
29
+
30
+ def get(self, key: str) -> PorticoJSON | None:
31
+ path = self._path(key)
32
+ if not path.exists():
33
+ return None
34
+ try:
35
+ return PorticoJSON.model_validate_json(path.read_text())
36
+ except Exception:
37
+ # Corrupt cache entry: drop it and miss.
38
+ path.unlink(missing_ok=True)
39
+ return None
40
+
41
+ def put(self, key: str, data: PorticoJSON) -> None:
42
+ self.root.mkdir(parents=True, exist_ok=True)
43
+ self._path(key).write_text(data.model_dump_json(indent=2))
44
+
45
+ def invalidate(self, key: str) -> None:
46
+ self._path(key).unlink(missing_ok=True)