portico-cli 0.1.0__py3-none-any.whl
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.
- portico/.DS_Store +0 -0
- portico/__init__.py +1 -0
- portico/analyzer.py +213 -0
- portico/cache.py +46 -0
- portico/cli.py +346 -0
- portico/config.py +13 -0
- portico/loaders/__init__.py +0 -0
- portico/loaders/base.py +45 -0
- portico/loaders/dir.py +223 -0
- portico/loaders/file.py +39 -0
- portico/loaders/repo.py +16 -0
- portico/loaders/text.py +21 -0
- portico/loaders/url.py +57 -0
- portico/providers/__init__.py +0 -0
- portico/providers/base.py +19 -0
- portico/providers/claude.py +67 -0
- portico/providers/gemini.py +6 -0
- portico/providers/openai.py +46 -0
- portico/render/__init__.py +25 -0
- portico/render/apex.py +58 -0
- portico/render/base.py +23 -0
- portico/render/color.py +20 -0
- portico/render/styles/__init__.py +0 -0
- portico/render/styles/default.py +265 -0
- portico/schema.py +41 -0
- portico/summarize.py +101 -0
- portico_cli-0.1.0.dist-info/METADATA +39 -0
- portico_cli-0.1.0.dist-info/RECORD +30 -0
- portico_cli-0.1.0.dist-info/WHEEL +4 -0
- portico_cli-0.1.0.dist-info/entry_points.txt +3 -0
portico/.DS_Store
ADDED
|
Binary file
|
portico/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
portico/analyzer.py
ADDED
|
@@ -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
|
+
)
|
portico/cache.py
ADDED
|
@@ -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)
|
portico/cli.py
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""portico CLI -- single entry point that wires the pipeline together.
|
|
2
|
+
|
|
3
|
+
Loader -> Summarizer (if oversized) -> Cache check -> Analyzer -> Renderer.
|
|
4
|
+
Failure classes route to exit codes per the spec failure taxonomy.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import shutil
|
|
12
|
+
import sys
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from portico import __version__
|
|
17
|
+
from portico.analyzer import F4MalformedJSON, analyze
|
|
18
|
+
from portico.cache import Cache, cache_key
|
|
19
|
+
from portico.config import (
|
|
20
|
+
get_anthropic_api_key,
|
|
21
|
+
get_default_model,
|
|
22
|
+
get_default_provider,
|
|
23
|
+
)
|
|
24
|
+
from portico.loaders.base import (
|
|
25
|
+
F1NetworkUnavailable,
|
|
26
|
+
F1NotFound,
|
|
27
|
+
F1RemoteInaccessible,
|
|
28
|
+
F2NotParseable,
|
|
29
|
+
F2TooLarge,
|
|
30
|
+
LoadedInput,
|
|
31
|
+
)
|
|
32
|
+
from portico.loaders.dir import load_dir
|
|
33
|
+
from portico.loaders.file import load_file
|
|
34
|
+
from portico.loaders.repo import load_repo
|
|
35
|
+
from portico.loaders.text import load_stdin, load_text
|
|
36
|
+
from portico.loaders.url import load_url
|
|
37
|
+
from portico.providers.base import (
|
|
38
|
+
LLMProvider,
|
|
39
|
+
ProviderAuthError,
|
|
40
|
+
ProviderTransportError,
|
|
41
|
+
)
|
|
42
|
+
from portico.providers.claude import DEFAULT_MODEL, ClaudeProvider
|
|
43
|
+
from portico.providers.gemini import GeminiProvider
|
|
44
|
+
from portico.providers.openai import OpenAIProvider
|
|
45
|
+
from portico.render import MAX_WIDTH, render
|
|
46
|
+
from portico.render.apex import generate_apex
|
|
47
|
+
from portico.render.color import ColorMode
|
|
48
|
+
from portico.schema import FitQuality, PorticoJSON
|
|
49
|
+
from portico.summarize import summarize
|
|
50
|
+
|
|
51
|
+
REFUSAL_TEMPLATE = """\
|
|
52
|
+
portico could not build a portico for this input.
|
|
53
|
+
|
|
54
|
+
reason: {reason}
|
|
55
|
+
|
|
56
|
+
input type detected: {theme}
|
|
57
|
+
|
|
58
|
+
what you can try:
|
|
59
|
+
• run with --force to render anyway
|
|
60
|
+
• narrow the input and try again
|
|
61
|
+
• verify the input is what you intended
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# --- Exit codes ---
|
|
66
|
+
EXIT_OK = 0
|
|
67
|
+
EXIT_F1_NOT_FOUND = 2
|
|
68
|
+
EXIT_F1_REMOTE = 3
|
|
69
|
+
EXIT_F1_NETWORK = 4
|
|
70
|
+
EXIT_F2_NOT_PARSEABLE = 5
|
|
71
|
+
EXIT_F2_TOO_LARGE = 6
|
|
72
|
+
EXIT_F4_MALFORMED = 7
|
|
73
|
+
EXIT_F4_TRANSPORT = 8
|
|
74
|
+
EXIT_F4_AUTH = 9
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class Args:
|
|
79
|
+
input_value: str | None
|
|
80
|
+
input_type: str | None
|
|
81
|
+
style: str
|
|
82
|
+
color: ColorMode
|
|
83
|
+
verbose: bool
|
|
84
|
+
width: int
|
|
85
|
+
json_out: bool
|
|
86
|
+
provider: str
|
|
87
|
+
model: str
|
|
88
|
+
no_cache: bool
|
|
89
|
+
diagnose: bool
|
|
90
|
+
force: bool
|
|
91
|
+
strict: bool
|
|
92
|
+
reapex: bool
|
|
93
|
+
reapex_seed: int | None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def make_provider(name: str) -> LLMProvider:
|
|
97
|
+
if name == "claude":
|
|
98
|
+
return ClaudeProvider(api_key=get_anthropic_api_key())
|
|
99
|
+
if name == "openai":
|
|
100
|
+
return OpenAIProvider()
|
|
101
|
+
if name == "gemini":
|
|
102
|
+
return GeminiProvider()
|
|
103
|
+
raise ValueError(f"unknown provider: {name}")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# Bare filenames like `essay.md` look like file paths even without a slash.
|
|
107
|
+
# Listing common extensions stops the heuristic from misreading "Trust scales..."
|
|
108
|
+
# (period + word) as a file path.
|
|
109
|
+
_FILE_EXTENSIONS = frozenset(
|
|
110
|
+
{
|
|
111
|
+
".txt", ".md", ".rst", ".log", ".csv", ".tsv", ".json", ".yaml", ".yml",
|
|
112
|
+
".toml", ".ini", ".conf", ".xml", ".html", ".htm", ".css", ".js", ".ts",
|
|
113
|
+
".tsx", ".jsx", ".py", ".rb", ".go", ".rs", ".java", ".c", ".h", ".cpp",
|
|
114
|
+
".hpp", ".cs", ".php", ".lua", ".sh", ".bash", ".zsh", ".sql", ".lock",
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _looks_like_path(value: str) -> bool:
|
|
120
|
+
if "/" in value or "\\" in value:
|
|
121
|
+
return True
|
|
122
|
+
if " " in value or "\n" in value:
|
|
123
|
+
return False
|
|
124
|
+
suffix = Path(value).suffix.lower()
|
|
125
|
+
return suffix in _FILE_EXTENSIONS
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def detect_input_type(value: str) -> str:
|
|
129
|
+
if value.startswith(("http://", "https://")):
|
|
130
|
+
return "url"
|
|
131
|
+
p = Path(value)
|
|
132
|
+
if p.exists():
|
|
133
|
+
return "dir" if p.is_dir() else "file"
|
|
134
|
+
if _looks_like_path(value):
|
|
135
|
+
# Path-shaped but missing -- let the file loader surface the F1 instead
|
|
136
|
+
# of silently treating the string as raw text.
|
|
137
|
+
return "file"
|
|
138
|
+
return "text"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def load(value: str | None, *, input_type: str | None) -> LoadedInput:
|
|
142
|
+
if value is None:
|
|
143
|
+
if sys.stdin.isatty():
|
|
144
|
+
raise F2NotParseable(
|
|
145
|
+
"no input provided. Pass a path, URL, raw text, or pipe via stdin "
|
|
146
|
+
"(e.g. `echo 'hello' | portico -`). See `portico --help`."
|
|
147
|
+
)
|
|
148
|
+
return load_stdin()
|
|
149
|
+
if value == "-":
|
|
150
|
+
return load_stdin()
|
|
151
|
+
t = input_type or detect_input_type(value)
|
|
152
|
+
if t == "url":
|
|
153
|
+
return load_url(value)
|
|
154
|
+
if t == "file":
|
|
155
|
+
return load_file(value)
|
|
156
|
+
if t == "dir":
|
|
157
|
+
return load_dir(value)
|
|
158
|
+
if t == "repo":
|
|
159
|
+
return load_repo(value)
|
|
160
|
+
return load_text(value)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def resolve_width(arg_width: int | None) -> int:
|
|
164
|
+
if arg_width is not None:
|
|
165
|
+
return arg_width
|
|
166
|
+
return min(shutil.get_terminal_size((MAX_WIDTH, 24)).columns, MAX_WIDTH)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def render_refusal(data: PorticoJSON) -> str:
|
|
170
|
+
return REFUSAL_TEMPLATE.format(reason=data.notes_on_fit, theme=data.theme)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def render_diagnostics(
|
|
174
|
+
loaded: LoadedInput, data: PorticoJSON, *, model: str, provider_name: str
|
|
175
|
+
) -> str:
|
|
176
|
+
return (
|
|
177
|
+
f"input: {loaded.source}\n"
|
|
178
|
+
f"type: {loaded.input_type}\n"
|
|
179
|
+
f"chars: {loaded.metadata.get('chars', len(loaded.text))}\n"
|
|
180
|
+
f"summarized: {loaded.metadata.get('summarized', False)}\n"
|
|
181
|
+
f"provider: {provider_name}\n"
|
|
182
|
+
f"model: {model}\n"
|
|
183
|
+
f"fit_quality: {data.fit_quality.value}\n"
|
|
184
|
+
f"notes_on_fit: {data.notes_on_fit}\n"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def run(args: Args, *, provider: LLMProvider | None = None) -> int:
|
|
189
|
+
"""The pipeline. `provider` injection is for tests."""
|
|
190
|
+
try:
|
|
191
|
+
loaded = load(args.input_value, input_type=args.input_type)
|
|
192
|
+
except F1NotFound as e:
|
|
193
|
+
print(f"portico: {e}", file=sys.stderr)
|
|
194
|
+
return EXIT_F1_NOT_FOUND
|
|
195
|
+
except F1RemoteInaccessible as e:
|
|
196
|
+
print(f"portico: {e}", file=sys.stderr)
|
|
197
|
+
return EXIT_F1_REMOTE
|
|
198
|
+
except F1NetworkUnavailable as e:
|
|
199
|
+
print(f"portico: {e}", file=sys.stderr)
|
|
200
|
+
return EXIT_F1_NETWORK
|
|
201
|
+
except F2NotParseable as e:
|
|
202
|
+
print(f"portico: {e}", file=sys.stderr)
|
|
203
|
+
return EXIT_F2_NOT_PARSEABLE
|
|
204
|
+
|
|
205
|
+
prov = provider or make_provider(args.provider)
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
loaded = summarize(loaded, provider=prov, model=args.model)
|
|
209
|
+
except F2TooLarge as e:
|
|
210
|
+
print(f"portico: {e}", file=sys.stderr)
|
|
211
|
+
return EXIT_F2_TOO_LARGE
|
|
212
|
+
except ProviderAuthError as e:
|
|
213
|
+
print(f"portico: {e}", file=sys.stderr)
|
|
214
|
+
return EXIT_F4_AUTH
|
|
215
|
+
except ProviderTransportError as e:
|
|
216
|
+
print(f"portico: {e}", file=sys.stderr)
|
|
217
|
+
return EXIT_F4_TRANSPORT
|
|
218
|
+
|
|
219
|
+
cache = Cache()
|
|
220
|
+
key = cache_key(loaded.text, provider=args.provider, model=args.model)
|
|
221
|
+
data: PorticoJSON | None = None
|
|
222
|
+
if not args.no_cache:
|
|
223
|
+
data = cache.get(key)
|
|
224
|
+
|
|
225
|
+
if data is None:
|
|
226
|
+
try:
|
|
227
|
+
result = analyze(loaded.text, provider=prov, model=args.model)
|
|
228
|
+
except F4MalformedJSON as e:
|
|
229
|
+
print(f"portico: {e}", file=sys.stderr)
|
|
230
|
+
return EXIT_F4_MALFORMED
|
|
231
|
+
except ProviderAuthError as e:
|
|
232
|
+
print(f"portico: {e}", file=sys.stderr)
|
|
233
|
+
return EXIT_F4_AUTH
|
|
234
|
+
except ProviderTransportError as e:
|
|
235
|
+
print(f"portico: {e}", file=sys.stderr)
|
|
236
|
+
return EXIT_F4_TRANSPORT
|
|
237
|
+
data = result.data
|
|
238
|
+
if not args.no_cache:
|
|
239
|
+
cache.put(key, data)
|
|
240
|
+
|
|
241
|
+
if args.diagnose:
|
|
242
|
+
print(render_diagnostics(loaded, data, model=args.model, provider_name=args.provider))
|
|
243
|
+
return EXIT_OK
|
|
244
|
+
|
|
245
|
+
if args.json_out:
|
|
246
|
+
print(json.dumps(data.model_dump(mode="json"), indent=2))
|
|
247
|
+
return EXIT_OK
|
|
248
|
+
|
|
249
|
+
# F3 routing -- abstain when fit_quality demands it.
|
|
250
|
+
fq = data.fit_quality
|
|
251
|
+
if fq == FitQuality.NOT_APPLICABLE:
|
|
252
|
+
print(render_refusal(data))
|
|
253
|
+
return EXIT_OK
|
|
254
|
+
if fq == FitQuality.FORCED and not args.force:
|
|
255
|
+
print(render_refusal(data))
|
|
256
|
+
return EXIT_OK
|
|
257
|
+
if fq == FitQuality.STRETCHED and args.strict:
|
|
258
|
+
print(render_refusal(data))
|
|
259
|
+
return EXIT_OK
|
|
260
|
+
|
|
261
|
+
apex_override: tuple[str, str] | None = None
|
|
262
|
+
apex_seed_label: str | None = None
|
|
263
|
+
if args.reapex:
|
|
264
|
+
finial, keystone, used_seed = generate_apex(args.reapex_seed)
|
|
265
|
+
apex_override = (finial, keystone)
|
|
266
|
+
apex_seed_label = f"apex seed: {used_seed}"
|
|
267
|
+
|
|
268
|
+
print(
|
|
269
|
+
render(
|
|
270
|
+
data,
|
|
271
|
+
width=args.width,
|
|
272
|
+
color=args.color,
|
|
273
|
+
verbose=args.verbose,
|
|
274
|
+
apex_override=apex_override,
|
|
275
|
+
apex_seed_label=apex_seed_label,
|
|
276
|
+
),
|
|
277
|
+
end="",
|
|
278
|
+
)
|
|
279
|
+
return EXIT_OK
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def parse_args(argv: list[str] | None = None) -> Args:
|
|
283
|
+
parser = argparse.ArgumentParser(prog="portico", description="Render any input as a portico.")
|
|
284
|
+
parser.add_argument("input", nargs="?", help="Path, URL, raw text, or '-' for stdin.")
|
|
285
|
+
parser.add_argument("--type", dest="input_type", choices=["text", "file", "dir", "url", "repo"])
|
|
286
|
+
parser.add_argument("--style", default="default")
|
|
287
|
+
parser.add_argument(
|
|
288
|
+
"--color",
|
|
289
|
+
choices=[c.value for c in ColorMode],
|
|
290
|
+
default=ColorMode.NEVER.value,
|
|
291
|
+
)
|
|
292
|
+
parser.add_argument("--verbose", "-v", action="store_true")
|
|
293
|
+
parser.add_argument("--width", type=int, default=None)
|
|
294
|
+
parser.add_argument("--json", dest="json_out", action="store_true")
|
|
295
|
+
parser.add_argument(
|
|
296
|
+
"--provider",
|
|
297
|
+
choices=["claude", "openai", "gemini"],
|
|
298
|
+
default=get_default_provider(),
|
|
299
|
+
)
|
|
300
|
+
parser.add_argument("--model", default=get_default_model() or DEFAULT_MODEL)
|
|
301
|
+
parser.add_argument("--no-cache", action="store_true")
|
|
302
|
+
parser.add_argument("--diagnose", action="store_true")
|
|
303
|
+
parser.add_argument("--force", action="store_true")
|
|
304
|
+
parser.add_argument("--strict", action="store_true")
|
|
305
|
+
parser.add_argument(
|
|
306
|
+
"--reapex",
|
|
307
|
+
nargs="?",
|
|
308
|
+
const="__random__",
|
|
309
|
+
default=None,
|
|
310
|
+
help="Roll a random symmetric apex ornament. Pass --reapex=SEED to pin one.",
|
|
311
|
+
)
|
|
312
|
+
parser.add_argument("--version", action="version", version=f"portico {__version__}")
|
|
313
|
+
parsed = parser.parse_args(argv)
|
|
314
|
+
|
|
315
|
+
reapex = parsed.reapex is not None
|
|
316
|
+
reapex_seed: int | None = None
|
|
317
|
+
if reapex and parsed.reapex != "__random__":
|
|
318
|
+
try:
|
|
319
|
+
reapex_seed = int(parsed.reapex)
|
|
320
|
+
except ValueError:
|
|
321
|
+
parser.error(
|
|
322
|
+
f"--reapex value must be an integer seed, got {parsed.reapex!r}"
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
return Args(
|
|
326
|
+
input_value=parsed.input,
|
|
327
|
+
input_type=parsed.input_type,
|
|
328
|
+
style=parsed.style,
|
|
329
|
+
color=ColorMode(parsed.color),
|
|
330
|
+
verbose=parsed.verbose,
|
|
331
|
+
width=resolve_width(parsed.width),
|
|
332
|
+
json_out=parsed.json_out,
|
|
333
|
+
provider=parsed.provider,
|
|
334
|
+
model=parsed.model,
|
|
335
|
+
no_cache=parsed.no_cache,
|
|
336
|
+
diagnose=parsed.diagnose,
|
|
337
|
+
force=parsed.force,
|
|
338
|
+
strict=parsed.strict,
|
|
339
|
+
reapex=reapex,
|
|
340
|
+
reapex_seed=reapex_seed,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def main() -> None:
|
|
345
|
+
args = parse_args()
|
|
346
|
+
sys.exit(run(args))
|
portico/config.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def get_anthropic_api_key() -> str | None:
|
|
5
|
+
return os.environ.get("ANTHROPIC_API_KEY")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_default_provider() -> str:
|
|
9
|
+
return os.environ.get("ARQII_PROVIDER", "claude")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_default_model() -> str | None:
|
|
13
|
+
return os.environ.get("ARQII_MODEL")
|
|
File without changes
|
portico/loaders/base.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Loader contract and shared exception hierarchy.
|
|
2
|
+
|
|
3
|
+
Every loader returns a `LoadedInput`. Failure cases map to F1/F2 categories
|
|
4
|
+
per the spec failure taxonomy and surface as specific exception subclasses
|
|
5
|
+
the CLI can route to exit codes 2-6.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class LoadedInput:
|
|
14
|
+
"""Output of a loader -- raw text plus diagnostic metadata."""
|
|
15
|
+
|
|
16
|
+
text: str
|
|
17
|
+
source: str
|
|
18
|
+
input_type: str # text | file | dir | url | repo
|
|
19
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class LoaderError(Exception):
|
|
23
|
+
"""Base for all loader-side failures."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# F1: input access (no LLM call)
|
|
27
|
+
class F1NotFound(LoaderError):
|
|
28
|
+
"""Local input does not exist or is unreadable. Exit 2."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class F1RemoteInaccessible(LoaderError):
|
|
32
|
+
"""Remote input returned 4xx/5xx or DNS failed. Exit 3."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class F1NetworkUnavailable(LoaderError):
|
|
36
|
+
"""Network required but unavailable. Exit 4."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# F2: input parsing (no LLM call)
|
|
40
|
+
class F2NotParseable(LoaderError):
|
|
41
|
+
"""Input cannot be parsed as text (binary blob, encrypted, etc.). Exit 5."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class F2TooLarge(LoaderError):
|
|
45
|
+
"""Input exceeds the hard size cap. Exit 6."""
|