Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ GROQ_API_KEY=
# Optional Docker (compose interpolates these; they are not baked into the image)
# FR_HTTP_BIND=127.0.0.1
# FR_SITE_ADDRESS=localhost
# FR_BOOTSTRAP_ADMIN_EMAIL=admin@localhost
# FR_BOOTSTRAP_ADMIN_PASSWORD=changeme
FR_BOOTSTRAP_ADMIN_EMAIL=admin@localhost
FR_BOOTSTRAP_ADMIN_PASSWORD=
# FR_ENTRA_TENANT_ID=
# FR_ENTRA_CLIENT_ID=
# FR_ENTRA_CLIENT_SECRET=
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: python -m pip install -e ".[dev]"
- run: python -m pip install -c constraints.txt -e ".[dev]"
- run: ruff check src tests
- run: mypy src/framework_reader/web/uploads.py src/framework_reader/web/images.py src/framework_reader/schema/entities.py
# Code tests only. No vendor/ fetch, no content-pack build, no signing keys. spec §10.C
- run: pytest -v
- name: Confirm vendor/ is absent in public CI
Expand Down
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml README.md ./
COPY pyproject.toml constraints.txt README.md ./
COPY src ./src
RUN pip install --no-cache-dir --root-user-action=ignore . \
RUN pip install --no-cache-dir --root-user-action=ignore -c constraints.txt . \
&& python -c "from pathlib import Path; import framework_reader.identity as i, framework_reader.prompts as p, framework_reader.web as w; \
assert (Path(i.__file__).parent/'schema.sql').is_file(); \
assert (Path(p.__file__).parent/'drafter.md').is_file(); \
Expand Down
8 changes: 7 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
.PHONY: install test build clean

install:
python -m pip install -e ".[dev]"
python -m pip install -c constraints.txt -e ".[dev]"

test:
.venv/bin/python -m pytest -v

.PHONY: check

check:
.venv/bin/python -m ruff check src tests
.venv/bin/python -m mypy src/framework_reader/web/uploads.py src/framework_reader/web/images.py src/framework_reader/schema/entities.py

build:
python -m framework_reader.pack.build

Expand Down
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ The content pack does not leave this machine; model calls use your key.

```bash
make install # install dependencies (incl. dev)
make check # Ruff + focused type checks
make test # run the test suite (no vendor/, no API key needed)
./scripts/fetch_sources.sh # fetch NIST public-domain sources into vendor/
make build # build build/content.sqlite (needs vendor/)
Expand Down Expand Up @@ -371,6 +372,7 @@ process, HTTP inside the container. TLS is terminated by Caddy in front,
not by uvicorn.

```bash
# First set FR_BOOTSTRAP_ADMIN_PASSWORD in .env.
# HTTP on http://127.0.0.1:8765 (set FR_HTTP_BIND=0.0.0.0 to listen on the LAN)
docker compose up --build

Expand All @@ -379,11 +381,10 @@ docker compose up --build
docker compose -f docker-compose.yml -f deploy/compose.https.yml up --build
```

Sign in at `/login` with **`admin@localhost` / `changeme`**. Change that
password before exposing the port. Override with `FR_BOOTSTRAP_ADMIN_EMAIL`
and `FR_BOOTSTRAP_ADMIN_PASSWORD`. The first account holds admin + author +
approver so a solo deploy can actually use the workbench; after it exists,
the door locks.
Before the first start, set a strong `FR_BOOTSTRAP_ADMIN_PASSWORD` in `.env`;
Compose refuses to start without it. `FR_BOOTSTRAP_ADMIN_EMAIL` defaults to
`admin@localhost`. The first account holds admin + author + approver so a solo
deploy can actually use the workbench; after it exists, the door locks.

User data lives in the `fr-data` volume (`FRAMEWORK_READER_HOME` and a copy of
the content pack). `FR_SECRET_KEY` comes from the environment; if you omit it,
Expand Down
56 changes: 56 additions & 0 deletions constraints.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Tested dependency set for repeatable CI and container builds.
# Refresh deliberately after running the full suite; pyproject.toml remains the
# human-readable declaration of direct dependencies.
annotated-doc==0.0.5
annotated-types==0.8.0
anthropic==0.125.0
anyio==4.14.2
certifi==2026.7.22
cffi==2.1.1
charset-normalizer==3.5.1
click==8.4.2
cryptography==50.0.0
distro==1.9.0
docstring_parser==0.18.0
et_xmlfile==2.0.0
fastapi==0.141.1
h11==0.16.0
httpcore==1.0.9
httpcore2==2.12.0
httpx==0.28.1
httpx2==2.12.0
idna==3.19
iniconfig==2.3.0
jiter==0.16.0
librt==0.15.0
markdown-it-py==4.2.0
mdurl==0.1.2
mypy==1.20.2
mypy_extensions==1.1.0
openpyxl==3.1.5
packaging==26.3
pathspec==1.1.1
pillow==12.3.0
pluggy==1.6.0
prompt_toolkit==3.0.53
pycparser==3.0
pydantic==2.13.4
pydantic_core==2.46.4
Pygments==2.21.0
PyJWT==2.13.0
pypdf==6.16.2
pytest==9.1.1
python-multipart==0.0.32
PyYAML==6.0.3
reportlab==5.0.1
rich==15.0.0
ruff==0.16.5
shellingham==1.5.4
sniffio==1.3.1
starlette==1.6.0
typer==0.27.1
typing-inspection==0.4.4
typing_extensions==4.16.0
truststore==0.10.4
uvicorn==0.52.4
wcwidth==0.8.2
4 changes: 4 additions & 0 deletions deploy/docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ if [ -n "${FR_BOOTSTRAP_ADMIN_EMAIL:-}" ]; then
echo "FR_BOOTSTRAP_ADMIN_EMAIL is set but FR_BOOTSTRAP_ADMIN_PASSWORD is empty." >&2
exit 1
fi
if [ "$FR_BOOTSTRAP_ADMIN_PASSWORD" = "changeme" ]; then
echo "FR_BOOTSTRAP_ADMIN_PASSWORD must not use the published default 'changeme'." >&2
exit 1
fi
fr account bootstrap --email "$FR_BOOTSTRAP_ADMIN_EMAIL" --password "$FR_BOOTSTRAP_ADMIN_PASSWORD"
fi

Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ services:
FRAMEWORK_READER_HOME: /data/home
FR_SECRET_KEY: ${FR_SECRET_KEY:-}
FR_BOOTSTRAP_ADMIN_EMAIL: ${FR_BOOTSTRAP_ADMIN_EMAIL:-admin@localhost}
FR_BOOTSTRAP_ADMIN_PASSWORD: ${FR_BOOTSTRAP_ADMIN_PASSWORD:-changeme}
FR_BOOTSTRAP_ADMIN_PASSWORD: ${FR_BOOTSTRAP_ADMIN_PASSWORD:?Set a strong bootstrap admin password in .env}
FR_ENTRA_TENANT_ID: ${FR_ENTRA_TENANT_ID:-}
FR_ENTRA_CLIENT_ID: ${FR_ENTRA_CLIENT_ID:-}
FR_ENTRA_CLIENT_SECRET: ${FR_ENTRA_CLIENT_SECRET:-}
Expand Down
10 changes: 9 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ dependencies = [
]

[project.optional-dependencies]
dev = ["pytest>=8.0"]
dev = ["pytest>=8.0", "httpx2>=2.12,<3", "ruff>=0.12,<1", "mypy>=1.15,<2"]

[project.scripts]
fr = "framework_reader.cli.main:app"
Expand All @@ -47,3 +47,11 @@ framework_reader = ["identity/*.sql", "pack/*.sql", "prompts/*.md", "web/static/
testpaths = ["tests"]
# tests/query/test_sample.py and tests/blindtest/test_sample.py share a basename.
addopts = ["--import-mode=importlib"]

[tool.ruff.lint]
select = ["E9", "F63", "F7", "F82"]

[tool.mypy]
python_version = "3.12"
warn_unused_configs = true
check_untyped_defs = true
3 changes: 3 additions & 0 deletions src/framework_reader/identity/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ CREATE TABLE IF NOT EXISTS audit_log (
detail TEXT NOT NULL DEFAULT ''
);

CREATE INDEX IF NOT EXISTS idx_audit_login_failures
ON audit_log(event, actor, at);

-- 运营开关。目前只有一条:allow_self_grant(设计 §4.3)
CREATE TABLE IF NOT EXISTS setting (
key TEXT PRIMARY KEY,
Expand Down
24 changes: 21 additions & 3 deletions src/framework_reader/identity/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@
# 会话:绝对 8 小时、空闲 1 小时(设计 §5.5)
ABSOLUTE_TTL = timedelta(hours=8)
IDLE_TTL = timedelta(hours=1)
SESSION_TOUCH_INTERVAL = timedelta(minutes=1)
INVITE_TTL = timedelta(days=7)
LOGIN_FAILURE_WINDOW = timedelta(minutes=5)
MAX_LOGIN_FAILURES = 5
# 从点「用公司账号登录」到 Entra 把人送回来。十分钟够慢的人输密码加二次验证,
# 又短到那串一次性状态不会在库里躺一天。
FLOW_TTL = timedelta(minutes=10)
Expand Down Expand Up @@ -557,6 +560,9 @@ def take_oidc_flow(self, state: str) -> dict | None:
def login(self, email: str, password: str) -> Session:
from framework_reader.identity.passwords import verify_password

email = email.strip().lower()
if self._recent_login_failures(email) >= MAX_LOGIN_FAILURES:
raise IdentityError("Wrong email or password.")
account = self.by_email(email)
conn = self._conn()
try:
Expand All @@ -578,6 +584,17 @@ def login(self, email: str, password: str) -> Session:
self.log("login.ok", actor=account.email)
return self.start_session(account)

def _recent_login_failures(self, email: str) -> int:
conn = self._conn()
try:
return conn.execute(
"SELECT COUNT(*) FROM audit_log "
"WHERE event = 'login.failed' AND actor = ? AND at >= ?",
(email, (_now() - LOGIN_FAILURE_WINDOW).isoformat()),
).fetchone()[0]
finally:
conn.close()

def start_session(self, account: Account) -> Session:
token = secrets.token_urlsafe(32)
csrf = secrets.token_urlsafe(32)
Expand Down Expand Up @@ -616,9 +633,10 @@ def resume(self, token: str) -> Session | None:
conn.execute("DELETE FROM session WHERE id = ?", (row["id"],))
conn.commit()
return None
conn.execute("UPDATE session SET last_seen = ? WHERE id = ?",
(now.isoformat(), row["id"]))
conn.commit()
if datetime.fromisoformat(row["last_seen"]) + SESSION_TOUCH_INTERVAL < now:
conn.execute("UPDATE session SET last_seen = ? WHERE id = ?",
(now.isoformat(), row["id"]))
conn.commit()
account_row = conn.execute(
"SELECT * FROM account WHERE id = ?", (row["account_id"],)
).fetchone()
Expand Down
5 changes: 2 additions & 3 deletions src/framework_reader/interpret/batch.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
DIFFERENTIATING_FIELDS,
Field,
Interpretation,
InterviewRecord,
InterpretationProvenance,
ModelRef,
)
Expand Down Expand Up @@ -38,7 +39,7 @@ def _is_empty(value) -> bool:
def _keep_human_content(
store: InterpretationStore, control_id: str, fresh: dict[str, Field],
blanks_only: bool = False,
) -> tuple[dict[str, Field], "InterviewRecord"]:
) -> tuple[dict[str, Field], InterviewRecord]:
"""重跑起草只覆盖 AI 写的部分。作者的原话与他改过的字段一律保留。

闸的方向是对的(W2 spec §6:作者说过的话不能丢),但粒度应当在字段上,
Expand All @@ -47,8 +48,6 @@ def _keep_human_content(
`blanks_only`:只补空格。凡是已经有字的字段一概不动,不管是谁写的——
用户点「补空缺」的意思就是「别碰我看过的那些」,包括他看过并认可的 AI 初稿。
"""
from framework_reader.interpret.model import InterviewRecord

if not store.exists(control_id):
return fresh, InterviewRecord()
previous = store.load(control_id)
Expand Down
53 changes: 53 additions & 0 deletions src/framework_reader/query/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ class ControlView(BaseModel):
status: str


class ControlSummary(ControlView):
has_interpretation: bool
interpretation_state: str | None = None


class SupersessionView(BaseModel):
control_id: str
label: str
Expand Down Expand Up @@ -330,6 +335,43 @@ def list_controls(
).fetchall()
return [ControlView(**dict(r)) for r in rows]

def framework_progress(self) -> dict[str, tuple[int, int]]:
"""每个框架的(叶子条款数,有解读的叶子条款数)。

目录页要的是聚合数,不该先取出所有条款再逐条查解读。
"""
rows = self._conn.execute(
"SELECT c.framework_id, COUNT(DISTINCT c.id) AS controls, "
" COUNT(DISTINCT i.control_id) AS interpreted "
"FROM all_control c "
"LEFT JOIN all_interpretation i ON i.control_id = c.id "
"WHERE c.status <> 'deprecated' "
" AND c.id NOT IN (SELECT parent_id FROM all_control "
" WHERE parent_id IS NOT NULL) "
"GROUP BY c.framework_id"
).fetchall()
return {
r["framework_id"]: (r["controls"], r["interpreted"])
for r in rows
}

def control_summaries(self, framework_id: str) -> list[ControlSummary]:
"""框架详情页所需的数据一次取齐,避免每条控制再查两次。"""
rows = self._conn.execute(
"SELECT c.id, c.framework_id, c.label, c.status, "
" COUNT(i.control_id) > 0 AS has_interpretation, "
" MAX(i.state) AS interpretation_state "
"FROM all_control c "
"LEFT JOIN all_interpretation i ON i.control_id = c.id "
"WHERE c.framework_id = ? AND c.status <> 'deprecated' "
" AND c.id NOT IN (SELECT parent_id FROM all_control "
" WHERE parent_id IS NOT NULL) "
"GROUP BY c.id, c.framework_id, c.label, c.status "
"ORDER BY c.id",
(framework_id,),
).fetchall()
return [ControlSummary(**dict(r)) for r in rows]

def list_interpreted(self, *, leaf_only: bool = True) -> list[ControlView]:
"""有解读的条款。首页每天三条从这里抽,没解读的学了也是空壳。"""
clauses = ["id IN (SELECT DISTINCT control_id FROM all_interpretation)"]
Expand Down Expand Up @@ -405,6 +447,17 @@ def interpretation(self, control_id: str, locale: str = "zh-CN") -> dict[str, di
for r in rows
}

def forbidden_outbound_texts(self) -> list[str]:
"""不得进入模型 payload 的内容包原文。

调用方只拿业务含义明确的数据,不拿底层连接去写裸 SQL。
"""
return [
r["body"] for r in self._conn.execute(
"SELECT body FROM original_text"
).fetchall()
]

def interpretation_state(
self, control_id: str, locale: str = "zh-CN"
) -> str | None:
Expand Down
Loading
Loading