Upload folder using huggingface_hub
Browse files- app.py +12 -3
- requirements.txt +1 -0
- src/pdf_report.py +185 -0
- src/personas.py +7 -2
- src/report.py +9 -3
- static/index.html +1 -1
app.py
CHANGED
|
@@ -148,9 +148,18 @@ def api_report(run_id: str):
|
|
| 148 |
run = _runs.get(run_id)
|
| 149 |
if not run or not run.get("result"):
|
| 150 |
raise HTTPException(404, "No report for this run.")
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
|
| 155 |
|
| 156 |
@app.get("/api/sample")
|
|
|
|
| 148 |
run = _runs.get(run_id)
|
| 149 |
if not run or not run.get("result"):
|
| 150 |
raise HTTPException(404, "No report for this run.")
|
| 151 |
+
from src.pdf_report import build_pdf
|
| 152 |
+
try:
|
| 153 |
+
pdf = build_pdf(run["result"])
|
| 154 |
+
except Exception: # PDF layout must never lose the report — fall back to markdown
|
| 155 |
+
import traceback
|
| 156 |
+
traceback.print_exc()
|
| 157 |
+
md = run["result"].get("report_md", "")
|
| 158 |
+
return PlainTextResponse(md, media_type="text/markdown", headers={
|
| 159 |
+
"Content-Disposition": f'attachment; filename="conclave-verdict-{run_id}.md"'})
|
| 160 |
+
from fastapi.responses import Response
|
| 161 |
+
return Response(pdf, media_type="application/pdf", headers={
|
| 162 |
+
"Content-Disposition": f'attachment; filename="conclave-verdict-{run_id}.pdf"'})
|
| 163 |
|
| 164 |
|
| 165 |
@app.get("/api/sample")
|
requirements.txt
CHANGED
|
@@ -7,3 +7,4 @@ yfinance>=1.4
|
|
| 7 |
huggingface_hub>=0.24
|
| 8 |
pyarrow>=16.0
|
| 9 |
certifi
|
|
|
|
|
|
| 7 |
huggingface_hub>=0.24
|
| 8 |
pyarrow>=16.0
|
| 9 |
certifi
|
| 10 |
+
reportlab>=4.0
|
src/pdf_report.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Conclave PDF report — typeset with reportlab (crucible's report stack, simplified).
|
| 2 |
+
Built directly from the result payload; tolerates missing sections (no LLM, no target).
|
| 3 |
+
"""
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import io
|
| 7 |
+
|
| 8 |
+
from reportlab.lib import colors
|
| 9 |
+
from reportlab.lib.pagesizes import A4
|
| 10 |
+
from reportlab.lib.styles import ParagraphStyle
|
| 11 |
+
from reportlab.lib.units import mm
|
| 12 |
+
from reportlab.platypus import (Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle)
|
| 13 |
+
|
| 14 |
+
INK = colors.HexColor("#141b18")
|
| 15 |
+
BRASS = colors.HexColor("#8a6d14")
|
| 16 |
+
DIM = colors.HexColor("#5a655e")
|
| 17 |
+
GOOD = colors.HexColor("#1e7a4a")
|
| 18 |
+
WARN = colors.HexColor("#9a6b12")
|
| 19 |
+
BAD = colors.HexColor("#a33a40")
|
| 20 |
+
LINE = colors.HexColor("#d8d4c6")
|
| 21 |
+
|
| 22 |
+
_ACTION_COLOR = {"Buy More": GOOD, "Keep": DIM, "Trim": WARN, "Sell": BAD}
|
| 23 |
+
_SEV_COLOR = {"good": GOOD, "warn": WARN, "bad": BAD, "OK": GOOD, "WATCH": WARN, "ALERT": BAD}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _sty(name, **kw):
|
| 27 |
+
base = dict(fontName="Helvetica", fontSize=9.5, leading=13, textColor=INK)
|
| 28 |
+
base.update(kw)
|
| 29 |
+
return ParagraphStyle(name, **base) # type: ignore[arg-type]
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
S_TITLE = _sty("t", fontName="Times-Bold", fontSize=24, leading=28)
|
| 33 |
+
S_TAG = _sty("tag", fontName="Times-Italic", fontSize=11, textColor=BRASS)
|
| 34 |
+
S_H2 = _sty("h2", fontName="Helvetica-Bold", fontSize=11, leading=15, textColor=BRASS,
|
| 35 |
+
spaceBefore=14, spaceAfter=5)
|
| 36 |
+
S_BODY = _sty("b")
|
| 37 |
+
S_VERDICT = _sty("v", fontName="Times-Roman", fontSize=12.5, leading=17)
|
| 38 |
+
S_DIM = _sty("d", fontSize=8, textColor=DIM)
|
| 39 |
+
S_CELL = _sty("c", fontSize=8.5, leading=11)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
_UNI = {"—": "-", "–": "-", "→": "->", "←": "<-", "≈": "~", "≥": ">=", "≤": "<=",
|
| 43 |
+
"↑": "up", "↓": "down", "•": "-", "‘": "'", "’": "'", "“": '"', "”": '"',
|
| 44 |
+
"₹": "Rs ", "…": "...", "⚠": "!", "✓": "OK", "✗": "x"}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _txt(x) -> str:
|
| 48 |
+
s = str(x if x is not None else "")
|
| 49 |
+
for u, a in _UNI.items():
|
| 50 |
+
s = s.replace(u, a)
|
| 51 |
+
s = s.replace("&", "&").replace("<", "<").replace(">", ">")
|
| 52 |
+
return s.encode("latin-1", "replace").decode("latin-1") # Helvetica-safe
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _pct(x, signed=True):
|
| 56 |
+
if not isinstance(x, (int, float)):
|
| 57 |
+
return "-"
|
| 58 |
+
return f"{x * 100:+.1f}%" if signed else f"{x * 100:.1f}%"
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _table(data, widths, header=True, aligns=None):
|
| 62 |
+
t = Table(data, colWidths=widths, repeatRows=1 if header else 0)
|
| 63 |
+
style = [("VALIGN", (0, 0), (-1, -1), "TOP"),
|
| 64 |
+
("LINEBELOW", (0, 0), (-1, -2), 0.4, LINE),
|
| 65 |
+
("TOPPADDING", (0, 0), (-1, -1), 4), ("BOTTOMPADDING", (0, 0), (-1, -1), 4)]
|
| 66 |
+
if header:
|
| 67 |
+
style += [("FONT", (0, 0), (-1, 0), "Helvetica-Bold", 8),
|
| 68 |
+
("TEXTCOLOR", (0, 0), (-1, 0), DIM),
|
| 69 |
+
("LINEBELOW", (0, 0), (-1, 0), 0.8, BRASS)]
|
| 70 |
+
for i, a in enumerate(aligns or []):
|
| 71 |
+
style.append(("ALIGN", (i, 0), (i, -1), a))
|
| 72 |
+
t.setStyle(TableStyle(style))
|
| 73 |
+
return t
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def build_pdf(res: dict) -> bytes:
|
| 77 |
+
buf = io.BytesIO()
|
| 78 |
+
doc = SimpleDocTemplate(buf, pagesize=A4, leftMargin=16 * mm, rightMargin=16 * mm,
|
| 79 |
+
topMargin=16 * mm, bottomMargin=16 * mm, title="Conclave — Portfolio Verdict")
|
| 80 |
+
E = []
|
| 81 |
+
E.append(Paragraph("Conclave", S_TITLE))
|
| 82 |
+
E.append(Paragraph("Five analysts. One verdict.", S_TAG))
|
| 83 |
+
meta = f"Generated {_txt(res.get('generated_at'))}"
|
| 84 |
+
ctx = res.get("context") or {}
|
| 85 |
+
if ctx:
|
| 86 |
+
meta += " · Context: " + ", ".join(f"{k}: {v}" for k, v in ctx.items())
|
| 87 |
+
E.append(Paragraph(_txt(meta), S_DIM))
|
| 88 |
+
E.append(Spacer(1, 8))
|
| 89 |
+
|
| 90 |
+
E.append(Paragraph(f"Portfolio health: <b>{res.get('health_score', '?')}/100</b>",
|
| 91 |
+
_sty("hs", fontName="Helvetica-Bold", fontSize=13)))
|
| 92 |
+
E.append(Spacer(1, 3))
|
| 93 |
+
E.append(Paragraph(_txt(res.get("verdict", "")), S_VERDICT))
|
| 94 |
+
|
| 95 |
+
E.append(Paragraph("KEY FINDINGS", S_H2))
|
| 96 |
+
for h in res.get("highlights", []):
|
| 97 |
+
c = _SEV_COLOR.get(str(h.get("severity")), DIM)
|
| 98 |
+
E.append(Paragraph(
|
| 99 |
+
f'<font color="#{c.hexval()[2:]}">■</font> <b>{_txt(h.get("title"))}</b> — '
|
| 100 |
+
f'{_txt(h.get("detail"))} <font size="7" color="#{DIM.hexval()[2:]}">({_txt(h.get("lens", "portfolio"))} lens)</font>',
|
| 101 |
+
S_BODY))
|
| 102 |
+
E.append(Spacer(1, 3))
|
| 103 |
+
|
| 104 |
+
E.append(Paragraph("RECOMMENDATIONS", S_H2))
|
| 105 |
+
rows: list[list] = [["Holding", "Weight", "P/L", "vs intrinsic", "Action", "Why"]]
|
| 106 |
+
for r in res.get("holdings", []):
|
| 107 |
+
c = _ACTION_COLOR.get(r.get("action"), DIM)
|
| 108 |
+
rows.append([
|
| 109 |
+
Paragraph(f"<b>{_txt(r['ticker'])}</b><br/><font size='7' color='#{DIM.hexval()[2:]}'>{_txt(r.get('name'))}</font>", S_CELL),
|
| 110 |
+
_pct(r.get("weight"), signed=False), _pct(r.get("pl_pct")), _pct(r.get("upside")),
|
| 111 |
+
Paragraph(f"<b><font color='#{c.hexval()[2:]}'>{_txt(r.get('action'))}</font></b>", S_CELL),
|
| 112 |
+
Paragraph(_txt(r.get("rationale")), S_CELL)])
|
| 113 |
+
E.append(_table(rows, [28 * mm, 15 * mm, 16 * mm, 18 * mm, 16 * mm, 84 * mm],
|
| 114 |
+
aligns=["LEFT", "RIGHT", "RIGHT", "RIGHT", "LEFT", "LEFT"]))
|
| 115 |
+
|
| 116 |
+
E.append(Paragraph("PORTFOLIO REVIEW", S_H2))
|
| 117 |
+
st = res.get("strategist") or {}
|
| 118 |
+
if st.get("strategy_read"):
|
| 119 |
+
E.append(Paragraph(f"<b>Strategy read:</b> {_txt(st['strategy_read'])}", S_BODY))
|
| 120 |
+
if st.get("style_tilt"):
|
| 121 |
+
E.append(Paragraph(f"<b>Style tilt:</b> {_txt(st['style_tilt'])}", S_BODY))
|
| 122 |
+
if st.get("gaps"):
|
| 123 |
+
E.append(Paragraph("<b>Missing exposures:</b> " + _txt(", ".join(map(str, st["gaps"]))), S_BODY))
|
| 124 |
+
E.append(Spacer(1, 5))
|
| 125 |
+
for c0 in res.get("risk_scorecard", []):
|
| 126 |
+
c = _SEV_COLOR.get(str(c0.get("status")), DIM)
|
| 127 |
+
E.append(Paragraph(
|
| 128 |
+
f"<font color='#{c.hexval()[2:]}'><b>[{_txt(c0.get('status'))}]</b></font> "
|
| 129 |
+
f"<b>{_txt(c0.get('check'))}</b> — {_txt(c0.get('detail'))}", S_BODY))
|
| 130 |
+
E.append(Spacer(1, 2))
|
| 131 |
+
sa = res.get("sector_allocation") or {}
|
| 132 |
+
if sa:
|
| 133 |
+
E.append(Spacer(1, 4))
|
| 134 |
+
E.append(Paragraph("<b>Sector mix:</b> " + _txt(" · ".join(
|
| 135 |
+
f"{s} {w * 100:.0f}%" for s, w in sa.items())), S_BODY))
|
| 136 |
+
|
| 137 |
+
alts = res.get("alternatives") or []
|
| 138 |
+
if alts:
|
| 139 |
+
E.append(Paragraph("WORTH A LOOK — GAP FILLERS", S_H2))
|
| 140 |
+
for a in alts:
|
| 141 |
+
sig = " · ".join(a.get("signals") or [])
|
| 142 |
+
E.append(Paragraph(
|
| 143 |
+
f"<b>{_txt(a.get('ticker'))}</b> — {_txt(a.get('reason'))}"
|
| 144 |
+
+ (f" <font size='7' color='#{DIM.hexval()[2:]}'>[{_txt(sig)}]</font>" if sig else ""),
|
| 145 |
+
S_BODY))
|
| 146 |
+
E.append(Spacer(1, 3))
|
| 147 |
+
|
| 148 |
+
target = res.get("target_portfolio") or []
|
| 149 |
+
if target:
|
| 150 |
+
E.append(Paragraph("RECOMMENDED TARGET PORTFOLIO", S_H2))
|
| 151 |
+
E.append(Paragraph("Committee conviction sizes positions under risk-profile caps. "
|
| 152 |
+
"Educational illustration, not advice.", S_DIM))
|
| 153 |
+
E.append(Spacer(1, 4))
|
| 154 |
+
rows = [["Holding", "Sector", "Current", "Target", "Change"]]
|
| 155 |
+
for r in target:
|
| 156 |
+
name = r["ticker"] + (" (new)" if r.get("is_new") else "")
|
| 157 |
+
rows.append([_txt(name), _txt(r.get("sector")), _pct(r["current_w"], signed=False),
|
| 158 |
+
_pct(r["target_w"], signed=False), f"{r['delta'] * 100:+.1f}pp"])
|
| 159 |
+
E.append(_table(rows, [38 * mm, 55 * mm, 22 * mm, 22 * mm, 22 * mm],
|
| 160 |
+
aligns=["LEFT", "LEFT", "RIGHT", "RIGHT", "RIGHT"]))
|
| 161 |
+
|
| 162 |
+
if res.get("llm_used"):
|
| 163 |
+
E.append(Paragraph("APPENDIX — ANALYST COMMITTEE DETAIL", S_H2))
|
| 164 |
+
for r in res.get("holdings", []):
|
| 165 |
+
E.append(Paragraph(
|
| 166 |
+
f"<b>{_txt(r['ticker'])}</b> · {_txt(r.get('action'))} · committee conviction "
|
| 167 |
+
f"{r.get('conviction')}/100 across {r.get('n_lenses')} lenses"
|
| 168 |
+
+ (f" · intrinsic {_txt(r['intrinsic'])} ({_pct(r.get('upside'))} vs price, {_txt(r.get('lifecycle'))})"
|
| 169 |
+
if r.get("intrinsic") else ""), S_BODY))
|
| 170 |
+
scores = r.get("lens_scores") or {}
|
| 171 |
+
if scores:
|
| 172 |
+
E.append(Paragraph("Lens scores: " + _txt(" · ".join(
|
| 173 |
+
f"{k} {v:.0f}" for k, v in scores.items())), S_DIM))
|
| 174 |
+
for lab, key in (("Strengths", "strengths"), ("Risks", "risks")):
|
| 175 |
+
items = r.get(key) or []
|
| 176 |
+
if items:
|
| 177 |
+
E.append(Paragraph(f"<i>{lab}:</i> " + _txt(" • ".join(items)), S_DIM))
|
| 178 |
+
if r.get("flags"):
|
| 179 |
+
E.append(Paragraph("Guards: " + _txt(", ".join(r["flags"])), S_DIM))
|
| 180 |
+
E.append(Spacer(1, 6))
|
| 181 |
+
|
| 182 |
+
E.append(Spacer(1, 10))
|
| 183 |
+
E.append(Paragraph(_txt(res.get("disclaimer", "")), S_DIM))
|
| 184 |
+
doc.build(E)
|
| 185 |
+
return buf.getvalue()
|
src/personas.py
CHANGED
|
@@ -132,8 +132,13 @@ _CIO_SYSTEM = (
|
|
| 132 |
"1) verdict: ONE sentence describing the portfolio (its character + its biggest issue).\n"
|
| 133 |
"2) highlights: the 3-5 most decision-relevant findings across stock and portfolio level, "
|
| 134 |
"each with the number that proves it and which lens surfaced it.\n"
|
| 135 |
-
"3) rationale: for EACH holding, ONE
|
| 136 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
"4) alternatives: pick 2-4 tickers ONLY from the provided candidate list that best fill the "
|
| 138 |
"strategist's gaps and complement the existing holdings; each candidate carries its backing "
|
| 139 |
"signals (quant factor rank and/or smart-money 13F+insider accumulation) — cite the signal in "
|
|
|
|
| 132 |
"1) verdict: ONE sentence describing the portfolio (its character + its biggest issue).\n"
|
| 133 |
"2) highlights: the 3-5 most decision-relevant findings across stock and portfolio level, "
|
| 134 |
"each with the number that proves it and which lens surfaced it.\n"
|
| 135 |
+
"3) rationale: for EACH holding, ONE short sentence a non-finance friend would instantly "
|
| 136 |
+
"understand, justifying its given action. NO jargon and NO scores: never mention conviction "
|
| 137 |
+
"numbers, EV/EBITDA, PEG, beta, percentiles, moving averages or 'the committee' (the detail "
|
| 138 |
+
"view already shows all that). Translate the data into everyday meaning — e.g. 'Profits fell "
|
| 139 |
+
"sharply last year and this one stock is nearly half your portfolio' or 'The business keeps "
|
| 140 |
+
"growing strongly and the price is still reasonable for it'. At most one simple concrete "
|
| 141 |
+
"fact per sentence.\n"
|
| 142 |
"4) alternatives: pick 2-4 tickers ONLY from the provided candidate list that best fill the "
|
| 143 |
"strategist's gaps and complement the existing holdings; each candidate carries its backing "
|
| 144 |
"signals (quant factor rank and/or smart-money 13F+insider accumulation) — cite the signal in "
|
src/report.py
CHANGED
|
@@ -89,10 +89,16 @@ def _row_fallback(dec, dcf) -> str:
|
|
| 89 |
if not dec["scores"]:
|
| 90 |
up = dcf.get("upside")
|
| 91 |
if isinstance(up, (int, float)):
|
| 92 |
-
side = "
|
| 93 |
-
return f"
|
|
|
|
| 94 |
return "Quant view only — no AI panel available for this run."
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
|
| 97 |
|
| 98 |
def _pct(x):
|
|
|
|
| 89 |
if not dec["scores"]:
|
| 90 |
up = dcf.get("upside")
|
| 91 |
if isinstance(up, (int, float)):
|
| 92 |
+
side = "less" if up > 0 else "more"
|
| 93 |
+
return (f"The market is paying {abs(up) * 100:.0f}% {side} than our estimate of "
|
| 94 |
+
f"what the business is worth.")
|
| 95 |
return "Quant view only — no AI panel available for this run."
|
| 96 |
+
c = dec["conviction"]
|
| 97 |
+
mood = ("the analysts see serious problems here" if c < 35 else
|
| 98 |
+
"the analysts found more concerns than positives" if c < 45 else
|
| 99 |
+
"the analysts' views are balanced on this one" if c <= 62 else
|
| 100 |
+
"the analysts see clear positives outweighing the risks")
|
| 101 |
+
return f"On balance, {mood} — open the row for the detail."
|
| 102 |
|
| 103 |
|
| 104 |
def _pct(x):
|
static/index.html
CHANGED
|
@@ -166,7 +166,7 @@
|
|
| 166 |
|
| 167 |
<footer class="card footer">
|
| 168 |
<div class="footer-actions">
|
| 169 |
-
<a id="download" class="primary" href="#" download>Download full report</a>
|
| 170 |
<button class="ghost" id="rerun">↺ New analysis</button>
|
| 171 |
</div>
|
| 172 |
<div id="meta" class="meta"></div>
|
|
|
|
| 166 |
|
| 167 |
<footer class="card footer">
|
| 168 |
<div class="footer-actions">
|
| 169 |
+
<a id="download" class="primary" href="#" download>Download full report (PDF)</a>
|
| 170 |
<button class="ghost" id="rerun">↺ New analysis</button>
|
| 171 |
</div>
|
| 172 |
<div id="meta" class="meta"></div>
|