Compare commits

..
Author SHA1 Message Date
codex 5c26bbe8b5 docs: record SID-native Windows ACL checks
CI / tests-and-images (pull_request) Successful in 2m17s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-03 12:05:19 +03:00
codex 3d7df25d00 fix Windows ACL checks to enumerate SIDs directly
CI / tests-and-images (pull_request) Successful in 2m13s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-03 11:52:10 +03:00
agent 4d589eee95 Merge pull request 'Harden external release preparation and runtime compatibility' (#62) from fix/cd-release-runtime-readiness into main
CI / tests-and-images (push) Successful in 2m12s
Runner smoke test / runner-smoke (push) Successful in 3s
2026-11-03 07:48:07 +00:00
codex 3c17e2eb3b Document external Windows release handoff
CI / tests-and-images (pull_request) Successful in 2m14s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-03 10:41:53 +03:00
codex 54e30d4228 Make Windows harness cleanup safe for Git objects
CI / tests-and-images (pull_request) Successful in 2m16s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-03 10:35:49 +03:00
codex 00b0af69c2 Fix Windows ZIP mode fixture encoding
CI / tests-and-images (pull_request) Successful in 2m13s
Runner smoke test / runner-smoke (pull_request) Successful in 3s
2026-11-03 10:24:45 +03:00
codex 81ab7aac39 Harden Windows release mode harness
CI / tests-and-images (pull_request) Successful in 2m11s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-03 10:19:45 +03:00
codex 8748760594 Align Windows release bundle mode validation
CI / tests-and-images (pull_request) Successful in 2m14s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-03 10:12:52 +03:00
codex 9d553744a1 Harden Windows release bundle trust boundary
CI / tests-and-images (pull_request) Successful in 2m13s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-03 10:02:27 +03:00
codex 710c483f34 Harden external release preparation workflow
CI / tests-and-images (pull_request) Successful in 2m15s
Runner smoke test / runner-smoke (pull_request) Successful in 3s
2026-11-03 09:38:37 +03:00
agent f19faf628e Merge pull request 'Consolidate product, UI, and deployment plans' (#60) from feature/documentation-consolidation into main
CI / tests-and-images (push) Successful in 2m12s
Runner smoke test / runner-smoke (push) Successful in 2s
2026-11-03 06:08:33 +00:00
codex 9fc269283b docs: align documentation footer versions
CI / tests-and-images (pull_request) Successful in 2m14s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-11-03 09:02:33 +03:00
codex 826c1d8ca5 Merge current main into documentation consolidation
CI / tests-and-images (pull_request) Successful in 2m47s
Runner smoke test / runner-smoke (pull_request) Successful in 3s
2026-11-03 08:54:25 +03:00
agent 9a98fafefb Merge pull request 'Improve search ranking and support empty reviews' (#61) from fix/search-relevance-and-empty-reviews into main
CI / tests-and-images (push) Successful in 2m14s
Runner smoke test / runner-smoke (push) Successful in 2s
2026-11-02 23:44:12 +00:00
codex 849c19e2ab docs: record final search benchmark findings
CI / tests-and-images (pull_request) Successful in 2m12s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-03 02:38:36 +03:00
codex 1c7ddf2fcb Use DFS scoring for explicit multi-index search
CI / tests-and-images (pull_request) Successful in 2m14s
Runner smoke test / runner-smoke (pull_request) Successful in 3s
2026-11-03 02:20:23 +03:00
codex e653f3b50e Preserve multi-index scope in review snapshots
CI / tests-and-images (pull_request) Successful in 2m12s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-03 01:41:58 +03:00
codex 726b1ee745 Harden empty review validation and lease ranking
CI / tests-and-images (pull_request) Successful in 2m19s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-03 01:09:01 +03:00
codex aa6fabe970 Fix search lease terminology and empty reviews
CI / tests-and-images (pull_request) Successful in 2m19s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-03 00:47:33 +03:00
codex 7437aef346 Record documentation gates in changelog
CI / tests-and-images (pull_request) Successful in 2m17s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-03 00:22:21 +03:00
codex 849c7537ed Document atomic corpus publication gate
CI / tests-and-images (pull_request) Successful in 2m22s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-03 00:18:06 +03:00
codex ee03dc6cc7 Clarify data pipeline versions and corpus handoff gate
CI / tests-and-images (pull_request) Successful in 2m12s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-03 00:11:52 +03:00
codex 4cf96e3fa9 Address documentation roadmap review findings
CI / tests-and-images (pull_request) Successful in 2m22s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-02 23:58:47 +03:00
codex ba9564fca2 Narrow scratch ignores and clarify shared mockup
CI / tests-and-images (pull_request) Successful in 2m31s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-02 23:44:57 +03:00
codex 3eba203009 Complete transferred UI and operational criteria
CI / tests-and-images (pull_request) Successful in 2m22s
Runner smoke test / runner-smoke (pull_request) Successful in 3s
2026-11-02 23:43:11 +03:00
codex 3fd5695bb2 Clarify roadmap source history
CI / tests-and-images (pull_request) Successful in 2m23s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-02 23:41:46 +03:00
codex 42b3aa09a7 Consolidate product and deployment roadmap docs
CI / tests-and-images (pull_request) Successful in 2m26s
Runner smoke test / runner-smoke (pull_request) Successful in 3s
2026-11-02 23:40:45 +03:00
agent 7ece39f212 Merge pull request 'План развития платформы: сравнение с Токтом Онлайн' (#55) from feature/toktom-platform-development-plan into main
CI / tests-and-images (push) Successful in 3m4s
Runner smoke test / runner-smoke (push) Successful in 2s
2026-11-02 19:21:24 +00:00
codex bb3de5ee0f Merge branch 'main' of https://git.vakanaut.ru/admin/akyldash into feature/toktom-platform-development-plan
CI / tests-and-images (pull_request) Successful in 2m47s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
# Conflicts:
#	CHANGELOG.md
2026-11-02 22:04:25 +03:00
codex 1ca41f162e Add platform plan to changelog
CI / tests-and-images (pull_request) Successful in 2m21s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-02 21:53:26 +03:00
codex afb3c9aa03 Update platform plan against current main
CI / tests-and-images (pull_request) Successful in 3m18s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-02 19:04:48 +03:00
admin c8d0086a9f Merge pull request 'Подготовить безопасный процесс production-релизов' (#59) from feature/production-deployment-readiness into main
CI / tests-and-images (push) Successful in 2m51s
Runner smoke test / runner-smoke (push) Successful in 3s
Reviewed-on: #59
Reviewed-by: agent <agent@vakanaut.ru>
2026-11-01 19:43:47 +00:00
codex 6cab6fcc95 Document bounded external release trigger
CI / tests-and-images (pull_request) Successful in 2m13s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-01 12:03:08 +03:00
codex 8ede9f2123 Quote Windows task script argument
CI / tests-and-images (pull_request) Successful in 2m12s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-01 11:57:32 +03:00
codex ca5036abfe Do not interrupt active deployment on battery change
CI / tests-and-images (pull_request) Successful in 2m13s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-01 11:51:07 +03:00
codex 1c2a468516 Terminate timed-out release API helper groups
CI / tests-and-images (pull_request) Successful in 2m14s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-01 11:50:34 +03:00
codex b50cfa818d Add bounded external release runtime binding 2026-11-01 11:50:13 +03:00
codex 0630a774cf Document external deployment readiness limits
CI / tests-and-images (pull_request) Successful in 2m13s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-01 11:31:12 +03:00
codex 4a7e907be7 Clarify Windows validation evidence
CI / tests-and-images (pull_request) Successful in 3m12s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-31 01:02:08 +03:00
codex 96ed0949ca Clarify production handoff is not deployed 2026-10-31 00:58:56 +03:00
codex 3d267afaee Clean up failed backup publication safely 2026-10-31 00:53:12 +03:00
codex 4c4b51270c Harden Windows backup ACL preflight 2026-10-31 00:49:26 +03:00
codex 940b6c93f9 Fix external deployment recovery preflight 2026-10-31 00:45:52 +03:00
codex a54fef6a0c Make Windows backup receiver portable 2026-10-31 00:41:34 +03:00
codex d65b857c2a Add external backup release and rollback sessions 2026-10-31 00:35:18 +03:00
agent 6be28418ab docs: update deployment readiness changelog
CI / tests-and-images (pull_request) Successful in 2m15s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-26 23:00:50 +03:00
agent f8483badcf fix: validate Gitea workflow source identity
CI / tests-and-images (pull_request) Successful in 2m12s
Runner smoke test / runner-smoke (pull_request) Successful in 3s
2026-10-26 22:55:29 +03:00
agent 74aa167d16 ci: chain release and dry-run handoff
CI / tests-and-images (pull_request) Successful in 2m19s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-26 22:43:11 +03:00
admin f1573edfa7 Merge branch 'main' into feature/production-deployment-readiness
CI / tests-and-images (pull_request) Successful in 2m28s
Runner smoke test / runner-smoke (pull_request) Successful in 3s
2026-10-26 19:10:55 +00:00
admin f90161f380 Merge pull request 'Ввести риск-ориентированную политику Delivery First' (#58) from feature/delivery-first-risk-verification into main
CI / tests-and-images (push) Successful in 2m19s
Runner smoke test / runner-smoke (push) Successful in 1s
Reviewed-on: #58
Reviewed-by: agent <agent@vakanaut.ru>
2026-10-26 19:05:11 +00:00
agent 9a5a1a1a69 Merge current main into Delivery First PR
CI / tests-and-images (pull_request) Successful in 2m13s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-26 16:56:00 +03:00
agent 05fc47d7c3 docs: record Delivery First policy
CI / tests-and-images (pull_request) Successful in 2m15s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-26 16:49:59 +03:00
agent e40a32971d Fix release artifact checksum paths
CI / tests-and-images (pull_request) Successful in 2m48s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-26 16:41:35 +03:00
agent eff81c1504 docs: record production release workflow
CI / tests-and-images (pull_request) Successful in 2m17s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-26 13:00:33 +03:00
agent 779743fdb0 fix: validate release tags by Gitea run ID
CI / tests-and-images (pull_request) Successful in 2m18s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-26 10:56:59 +03:00
agent 8f331a002f docs: record artifact download verification
CI / tests-and-images (pull_request) Successful in 2m13s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-26 10:41:39 +03:00
agent d428919b78 fix: harden release artifact verification
CI / tests-and-images (pull_request) Successful in 2m16s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-26 10:40:53 +03:00
agent 9d8ce6b67f fix: address production release review findings
CI / tests-and-images (pull_request) Successful in 2m43s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-25 13:44:56 +03:00
agent 852d639c45 feat: prepare production deployment readiness
CI / tests-and-images (pull_request) Failing after 26s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-25 13:21:40 +03:00
agent b5adc09203 Merge pull request 'Исправить навигацию и capability администратора' (#57) from fix/admin-navigation-capabilities into main
CI / tests-and-images (push) Successful in 2m22s
Runner smoke test / runner-smoke (push) Successful in 2s
2026-10-24 20:33:30 +00:00
codex a153dc8765 docs: record admin navigation release changes
CI / tests-and-images (pull_request) Successful in 2m26s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-24 23:29:57 +03:00
codex 3620bb734c test: wait for localized user role
CI / tests-and-images (pull_request) Successful in 2m24s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-24 23:26:06 +03:00
codex 830e1d0f0d fix: localize account roles and align lab brand
CI / tests-and-images (pull_request) Successful in 2m26s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-24 23:17:43 +03:00
codex fd28ed2201 Добавить risk-based Delivery First policy
CI / tests-and-images (pull_request) Successful in 2m34s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-24 22:53:07 +03:00
codex a1bb3eab98 fix: align admin navigation with backend capability
CI / tests-and-images (pull_request) Successful in 2m43s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-24 22:32:30 +03:00
admin 8bd2b53469 Merge pull request 'Согласовать поиск, кабинет и лабораторию с макетом' (#56) from fix/uiux-visual-feedback into main
CI / tests-and-images (push) Successful in 2m43s
Runner smoke test / runner-smoke (push) Successful in 1s
Reviewed-on: #56
Reviewed-by: agent <agent@vakanaut.ru>
2026-10-24 19:02:01 +00:00
Codex 21325d4e5c Fix three UI navigation collisions
CI / tests-and-images (pull_request) Successful in 2m36s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-24 20:07:28 +03:00
Codex 48cb424afd Refine search, account, and lab layouts
CI / tests-and-images (pull_request) Successful in 2m32s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-24 19:49:55 +03:00
Codex e5a8326a5e Polish search, account, and lab screens
CI / tests-and-images (pull_request) Successful in 2m56s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-24 19:12:05 +03:00
agent 770e7930d9 docs: correct implementation versions in comparison baseline
CI / tests-and-images (pull_request) Successful in 2m30s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-24 18:56:33 +03:00
agent cb3ab76d2b docs: add platform development plan and Toktom gap analysis
CI / tests-and-images (pull_request) Successful in 2m52s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-24 18:53:53 +03:00
admin 01380d3c09 Merge pull request 'Исправления интерфейса Түндүк: поиск, лаборатория и кабинеты' (#54) from feature/uiux-tunduk-corrections into main
CI / tests-and-images (push) Successful in 2m50s
Runner smoke test / runner-smoke (push) Successful in 2s
Reviewed-on: #54
Reviewed-by: agent <agent@vakanaut.ru>
2026-10-23 19:44:49 +00:00
admin 6a3a6f8da5 fix: preserve search layout until panels fit
CI / tests-and-images (pull_request) Successful in 2m13s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-23 17:29:08 +03:00
admin 224646e9d7 fix: address UI review findings
CI / tests-and-images (pull_request) Successful in 2m19s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-23 17:17:01 +03:00
admin 1d5eda3d89 chore: normalize target screenshot permissions
CI / tests-and-images (pull_request) Successful in 2m21s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-23 16:46:54 +03:00
admin 8f58995fd0 fix: complete Tunduk interface corrections 2026-10-23 16:46:41 +03:00
admin 25c62d3fff Merge pull request 'Implement pixel-perfect legal search interface' (#53) from feature/frontend-pixel-perfect into main
CI / tests-and-images (push) Successful in 2m17s
Runner smoke test / runner-smoke (push) Successful in 2s
Reviewed-on: #53
Reviewed-by: agent <agent@vakanaut.ru>
2026-10-23 08:59:29 +00:00
agent 2e77f6c3df Document frontend search release in changelog
CI / tests-and-images (pull_request) Successful in 2m16s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-23 11:53:01 +03:00
agent 28e42e8b55 Clear stale source link while loading editions
CI / tests-and-images (pull_request) Successful in 2m12s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-23 11:46:16 +03:00
agent 2d3f0a35c6 Fix public document edition state
CI / tests-and-images (pull_request) Successful in 2m12s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-23 11:27:37 +03:00
agent aae9657b9e fix(review): position tutorial hints beside targets
CI / tests-and-images (pull_request) Successful in 2m25s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-23 11:08:57 +03:00
agent a2ef8e0bf0 fix(frontend): refine visual match and modal focus
CI / tests-and-images (pull_request) Successful in 2m18s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-23 10:31:22 +03:00
agent 5a80c021d3 feat(frontend): complete public search visual review
CI / tests-and-images (pull_request) Successful in 2m33s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-23 00:34:39 +03:00
agent 133d847608 clarify baseline status in frontend plan
CI / tests-and-images (pull_request) Successful in 2m16s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-23 00:17:26 +03:00
agent 043d9d632d refine search screen visual alignment
CI / tests-and-images (pull_request) Successful in 3m12s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-23 00:16:18 +03:00
agent d01fdabd68 feat(frontend): implement legal search interface
Runner smoke test / runner-smoke (pull_request) Failing after 13m50s
CI / tests-and-images (pull_request) Failing after 13m55s
2026-10-23 00:00:41 +03:00
agent 84ae32b8a3 Merge pull request 'Keep rootless Docker accessible under runner hardening' (#52) from fix/runner-protecthome-rootless-socket into main
CI / tests-and-images (push) Successful in 2m6s
Runner smoke test / runner-smoke (push) Successful in 2s
2026-10-21 06:04:16 +00:00
agent 1f6129e009 Document runner ProtectHome fix
CI / tests-and-images (pull_request) Successful in 2m8s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-21 09:00:43 +03:00
agent d6799ec59b Preserve runner runtime path carve-out
CI / tests-and-images (pull_request) Successful in 2m6s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-21 08:57:00 +03:00
agent fcab9e1239 Keep rootless Docker socket visible to runner
CI / tests-and-images (pull_request) Successful in 2m8s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-21 08:54:10 +03:00
agent d2fb4b81c0 Merge pull request 'Add bounded current MinJust API staging' (#51) from fix/current-minjust-api-ingestion into main
CI / tests-and-images (push) Successful in 2m7s
Runner smoke test / runner-smoke (push) Successful in 5s
2026-10-21 05:44:35 +00:00
agent f922f6e8c7 Document current MinJust staging release
CI / tests-and-images (pull_request) Successful in 2m6s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-21 08:38:34 +03:00
agent 06e350fb1e Fix MinJust overlap test under umask 000
CI / tests-and-images (pull_request) Successful in 2m9s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-21 08:34:07 +03:00
agent e522b2eee3 Test rejection of unknown corpus normalizer versions
CI / tests-and-images (pull_request) Failing after 8s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-20 02:00:07 +03:00
agent 228dfdaca0 Harden MinJust staging and legacy corpus verification
CI / tests-and-images (pull_request) Failing after 9s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-20 01:59:32 +03:00
agent 6357648c9c Add exact current MinJust API staging adapter
CI / tests-and-images (pull_request) Successful in 2m7s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-20 01:39:36 +03:00
agent b845316ee7 Merge pull request 'fix: validate digest-pinned rollback images safely' (#50) from fix/rollback-image-digest-reference into main
CI / tests-and-images (push) Successful in 2m6s
Runner smoke test / runner-smoke (push) Successful in 2s
2026-10-19 21:41:47 +00:00
agent 2d3b34f34b Merge remote-tracking branch 'origin/main' into fix/rollback-image-digest-reference
CI / tests-and-images (pull_request) Successful in 2m10s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
# Conflicts:
#	CHANGELOG.md
#	docs/operations/CURRENT_STATE.md
2026-10-20 00:35:46 +03:00
agent cc8cc1bce6 Merge pull request 'Add offline release manifest contract' (#49) from feature/release-manifest-contract into main
CI / tests-and-images (push) Successful in 2m8s
Runner smoke test / runner-smoke (push) Successful in 1s
2026-10-19 21:29:33 +00:00
agent a68d3c315e docs: record rollback preflight limitation
CI / tests-and-images (pull_request) Successful in 2m5s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-20 00:27:44 +03:00
agent ba669fad34 Merge remote-tracking branch 'origin/main' into feature/release-manifest-contract
CI / tests-and-images (pull_request) Successful in 2m5s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
# Conflicts:
#	CHANGELOG.md
2026-10-20 00:18:34 +03:00
codex 8f64131131 Merge remote-tracking branch 'origin/main' into fix/rollback-image-digest-reference
CI / tests-and-images (pull_request) Successful in 2m6s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-20 00:15:33 +03:00
codex 802bfd117e test: assert digest rollback fails before side effects
CI / tests-and-images (pull_request) Successful in 2m6s
Runner smoke test / runner-smoke (pull_request) Successful in 3s
2026-10-20 00:13:55 +03:00
agent 2281705c95 Merge pull request 'Make corpus inventory scalable and resumable' (#48) from feature/resumable-corpus-inventory into main
CI / tests-and-images (push) Successful in 2m9s
Runner smoke test / runner-smoke (push) Successful in 1s
2026-10-19 21:10:48 +00:00
codex fc0473e7e2 fix: validate digest-pinned rollback images safely 2026-10-20 00:10:25 +03:00
agent e12a8a919c Document release manifest preparation scope
CI / tests-and-images (pull_request) Successful in 2m6s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-18 22:07:45 +03:00
agent 37050b1458 Add validated release manifest contract
CI / tests-and-images (pull_request) Successful in 2m7s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-18 22:02:09 +03:00
agent 6bd4b05af2 Document corpus inventory safety gates
CI / tests-and-images (pull_request) Successful in 2m30s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-18 19:39:34 +03:00
agent 0da2bcd82d Lock corpus artifacts during comparison
CI / tests-and-images (pull_request) Successful in 2m31s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-18 19:33:52 +03:00
agent 0979b8f544 Reject empty corpus staging comparisons
CI / tests-and-images (pull_request) Successful in 2m33s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-18 19:28:27 +03:00
agent 4d106903e1 Fix corpus inventory indexing and provenance
CI / tests-and-images (pull_request) Successful in 2m25s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-18 19:22:00 +03:00
agent 4b2e5578cd Add resumable corpus inventory verification 2026-10-18 15:29:14 +03:00
agent 0e276ae652 Merge pull request 'ci: isolate image builds on rootless runner' (#47) from feature/autonomous-review-workflow into main
CI / tests-and-images (push) Successful in 2m5s
Runner smoke test / runner-smoke (push) Successful in 2s
2026-10-18 05:58:19 +00:00
codex 554a7ab5ff docs: qualify runner resource verification
CI / tests-and-images (pull_request) Successful in 2m6s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-18 08:55:16 +03:00
codex cf99f4532f docs: record successful isolated CI build
CI / tests-and-images (pull_request) Successful in 2m4s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-18 08:51:21 +03:00
codex 5d72bb092d fix: pass runner workspace to container actions
CI / tests-and-images (pull_request) Successful in 2m26s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-18 08:46:05 +03:00
codex d96fc4d36b ci: set container action entrypoints
CI / tests-and-images (pull_request) Failing after 9s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-18 08:37:44 +03:00
codex 436806a834 ci: exercise root guards under unprivileged uid
CI / tests-and-images (pull_request) Failing after 16s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-18 08:32:48 +03:00
codex 3a89dc2196 ci: build production images with isolated Kaniko
CI / tests-and-images (pull_request) Failing after 15s
Runner smoke test / runner-smoke (pull_request) Successful in 8s
2026-10-18 08:17:57 +03:00
agent 69c62466ca docs: complete autonomous review gates
Runner smoke test / runner-smoke (pull_request) Successful in 2s
CI / tests-and-images (pull_request) Has been cancelled
2026-10-17 23:13:07 +03:00
agent 36892aef77 docs: define autonomous review workflow
Runner smoke test / runner-smoke (pull_request) Successful in 1s
CI / tests-and-images (pull_request) Has been cancelled
2026-10-17 23:07:12 +03:00
admin ac611cc878 Merge pull request 'Fix rootless Docker HOME for runner' (#45) from fix/rootless-docker-home into main
CI / tests-and-images (push) Has been cancelled
Runner smoke test / runner-smoke (push) Successful in 3s
Reviewed-on: #45
Reviewed-by: agent <agent@vakanaut.ru>
2026-10-14 14:45:57 +00:00
agent 9ec03200fb fix: restart rootless Docker with updated environment
Runner smoke test / runner-smoke (pull_request) Successful in 4s
CI / tests-and-images (pull_request) Has been cancelled
2026-10-14 17:41:24 +03:00
agent 03c5809d69 fix: persist rootless Docker unit environment
Runner smoke test / runner-smoke (pull_request) Successful in 1m0s
CI / tests-and-images (pull_request) Has been cancelled
2026-10-14 11:48:57 +03:00
agent 00922e6c09 fix: make rootless Docker home writable
CI / tests-and-images (pull_request) Has been cancelled
Runner smoke test / runner-smoke (pull_request) Has been cancelled
2026-10-12 22:18:51 +03:00
admin e6b099cf31 Merge pull request 'Make runner installer safe to resume' (#44) from fix/runner-installer-resume into main
CI / tests-and-images (push) Has been cancelled
Runner smoke test / runner-smoke (push) Has been cancelled
Reviewed-on: #44
Reviewed-by: agent <agent@vakanaut.ru>
2026-10-11 23:22:16 +00:00
agent d8a0646829 Protect runner mount image from CI path replacement
CI / tests-and-images (pull_request) Has been cancelled
Runner smoke test / runner-smoke (pull_request) Has been cancelled
2026-10-12 02:16:13 +03:00
agent 433c3059b9 Create runner image through a pinned file descriptor
CI / tests-and-images (pull_request) Has been cancelled
Runner smoke test / runner-smoke (pull_request) Has been cancelled
2026-10-12 02:01:19 +03:00
agent 4110aa54c9 Make runner setup race-safe and resumable
CI / tests-and-images (pull_request) Has been cancelled
Runner smoke test / runner-smoke (pull_request) Has been cancelled
2026-10-12 01:41:20 +03:00
agent db6ded4075 Harden runner installer path and mount checks
CI / tests-and-images (pull_request) Has been cancelled
Runner smoke test / runner-smoke (pull_request) Has been cancelled
2026-10-12 01:23:03 +03:00
agent b68421bb5b Make runner installer safe to resume
CI / tests-and-images (pull_request) Has been cancelled
Runner smoke test / runner-smoke (pull_request) Has been cancelled
2026-10-12 01:16:11 +03:00
admin 223e06ee77 Merge pull request 'Prepare isolated AkylDash infrastructure and repository CI runner' (#43) from feature/infrastructure-deployment-phase-2 into main
CI / tests-and-images (push) Has been cancelled
Runner smoke test / runner-smoke (push) Has been cancelled
Reviewed-on: #43
Reviewed-by: agent <agent@vakanaut.ru>
2026-10-11 21:57:01 +00:00
agent 52027ecf81 Accept empty subordinate ID allocation files
CI / tests-and-images (pull_request) Has been cancelled
Runner smoke test / runner-smoke (pull_request) Has been cancelled
2026-10-12 00:51:29 +03:00
agent e5a4dab5f3 Fix runner installer ID allocation and guards
CI / tests-and-images (pull_request) Has been cancelled
Runner smoke test / runner-smoke (pull_request) Has been cancelled
2026-10-12 00:47:19 +03:00
agent e034fdb816 feat(infra): prepare isolated repository CI runner
CI / tests-and-images (pull_request) Has been cancelled
Runner smoke test / runner-smoke (pull_request) Has been cancelled
2026-10-12 00:35:42 +03:00
admin a34f516de8 Merge pull request 'Подготовить воспроизводимую инфраструктуру production VPS' (#42) from feature/infrastructure-documentation-and-bootstrap into main
CI / tests-and-images (push) Has been cancelled
Reviewed-on: #42
Reviewed-by: agent <agent@vakanaut.ru>
2026-10-11 20:22:22 +00:00
agent 3b41d1f20f Guard volume gaps and back up initialized production
CI / tests-and-images (pull_request) Has been cancelled
2026-10-11 23:17:48 +03:00
agent 752266d408 Back up preloaded corpus before first deployment
CI / tests-and-images (pull_request) Has been cancelled
2026-10-11 22:59:23 +03:00
agent f6c9b9883a Verify offline data backup without containers
CI / tests-and-images (pull_request) Has been cancelled
2026-10-11 22:51:44 +03:00
agent d6d029d98c Fix production backup bootstrap guidance
CI / tests-and-images (pull_request) Has been cancelled
2026-10-11 22:50:39 +03:00
agent 9084fd4f16 Test restricted verifier argument guard
CI / tests-and-images (pull_request) Has been cancelled
2026-10-11 19:40:58 +03:00
agent 02cf4e6e95 Expose drift verifier through a fixed sudo helper
CI / tests-and-images (pull_request) Has been cancelled
2026-10-11 19:39:02 +03:00
agent 711700fcc9 Document WSL access and verify VPS state
CI / tests-and-images (pull_request) Has been cancelled
2026-10-11 19:35:16 +03:00
agent 6f809d3196 Add guarded rollback and recovery apply tools
CI / tests-and-images (pull_request) Has been cancelled
2026-10-11 19:30:22 +03:00
agent 16f97d5f73 Guard recovery bootstrap from production host
CI / tests-and-images (pull_request) Has been cancelled
2026-10-11 19:16:54 +03:00
agent 97803ca5de Add cold backup and staged restore tooling
CI / tests-and-images (pull_request) Has been cancelled
2026-10-11 19:12:02 +03:00
agent fb556228c7 Configure Caddy HTTPS proxy for production
CI / tests-and-images (pull_request) Has been cancelled
2026-10-11 18:57:08 +03:00
agent e42c314552 Зафиксировать исключения дизайн-детектора для лаборатории
CI / tests-and-images (pull_request) Has been cancelled
2026-10-11 18:42:13 +03:00
agent 7124c82bb6 Обновить статус инфраструктурных работ
CI / tests-and-images (pull_request) Has been cancelled
2026-10-11 18:37:24 +03:00
agent 892781522d Закрепить версию Python в CI
CI / tests-and-images (pull_request) Has been cancelled
2026-10-11 18:36:36 +03:00
agent 5490b22275 Зафиксировать фактическую доступность production домена
CI / tests-and-images (pull_request) Has been cancelled
2026-10-11 18:35:04 +03:00
agent 5c78f7ebb8 Усилить read-only drift-проверку VPS
CI / tests-and-images (pull_request) Has been cancelled
2026-10-11 18:32:14 +03:00
agent d03da40eee Добавить воспроизводимую основу инфраструктуры
CI / tests-and-images (pull_request) Has been cancelled
2026-10-11 18:27:15 +03:00
admin fb1a67044c Merge pull request 'Исправить адаптивность поиска на планшетной ширине' (#41) from fix/pr-40-review-feedback into main
Reviewed-on: #41
Reviewed-by: agent <agent@vakanaut.ru>
2026-10-11 10:56:59 +00:00
agent 7ed85dae34 fix: stack search columns on tablet widths 2026-10-11 13:37:31 +03:00
admin 88fd84a515 Merge pull request 'Создать авторизованный frontend поиска и лаборатории' (#40) from feature/tunduk-authenticated-frontend into main
Reviewed-on: #40
Reviewed-by: agent <agent@vakanaut.ru>
2026-10-10 18:30:31 +00:00
agent 6de2f47933 docs: note password change security fix 2026-10-10 21:11:59 +03:00
agent 7912fdd8b6 fix: require current password when changing credentials 2026-10-10 21:10:15 +03:00
agent 075e58fa07 feat: show user sign-in and password reset dates 2026-10-10 20:17:57 +03:00
agent 734631a12d docs: record authenticated frontend in changelog 2026-10-10 18:40:25 +03:00
agent 0a30adb85f feat: add authenticated search frontend 2026-10-10 18:34:07 +03:00
admin 93e5fc5837 Merge pull request 'Review second wave of search relevance' (#39) from feature/search-benchmark-report into main
Reviewed-on: #39
Reviewed-by: agent <agent@vakanaut.ru>
2026-09-26 07:18:15 +00:00
agent ac4380c8b3 Merge branch 'main' of https://git.vakanaut.ru/agent/akyldash into feature/search-benchmark-report 2026-09-26 10:06:50 +03:00
agent 2b5b79c6c2 Reject one-term fallback after relevance review 2026-09-26 07:19:48 +03:00
agent 9a80e08739 Capture production search baseline before rollout 2026-09-26 00:59:23 +03:00
agent 3b03331955 Record search tuning review and rollout status 2026-09-26 00:55:06 +03:00
agent 20d58963e7 Document search tuning in changelog 2026-09-26 00:52:37 +03:00
agent a13343647a Correct accepted search benchmark latency 2026-09-26 00:51:00 +03:00
agent 4e2e12f83a Clarify benchmark metric generations 2026-09-26 00:49:16 +03:00
agent e1b7d502b2 Tune fallback search matching and add wave two benchmark 2026-09-26 00:46:39 +03:00
admin 2ffb02428b Merge pull request 'Improve search recall for multiword queries' (#38) from feature/search-benchmark-report into main
Reviewed-on: #38
Reviewed-by: agent <agent@vakanaut.ru>
2026-09-25 19:45:02 +00:00
agent 9c3cbee141 fix: preserve topic terms after act titles 2026-09-25 22:39:24 +03:00
agent feb0ddde32 fix: recognize consumer law titles in fallback 2026-09-25 20:27:48 +03:00
agent 3afac00394 fix: retain fallback for act topic searches 2026-09-25 20:25:41 +03:00
agent cf510f08f8 docs: note relaxed search matching 2026-09-25 09:04:53 +03:00
agent a3d82b45b5 fix: guard relaxed search from stopwords 2026-09-25 09:03:50 +03:00
agent 6f8b668907 fix: require all tokens for short fallbacks 2026-09-25 09:02:01 +03:00
agent 3b56445bb2 fix: constrain relaxed search matches 2026-09-25 08:59:51 +03:00
agent bc57cb82fe fix: relax content search for long queries 2026-09-25 08:52:45 +03:00
admin 9832c0c795 Merge pull request 'feature/tax-profit-search-intent' (#37) from feature/tax-profit-search-intent into main
Reviewed-on: agent/akyldash#37
2026-09-18 05:56:44 +00:00
agent 5e029be5ee docs: refresh search work handoff 2026-09-18 08:50:06 +03:00
agent 6d393d38bb Record search work handoff status 2026-09-16 22:40:13 +03:00
agent 2c43c7c9ad Preserve cross-field search matching 2026-09-16 22:34:18 +03:00
agent 14e86ccdd8 Improve tax profit search relevance 2026-09-16 22:31:35 +03:00
admin 47d51cd36b Merge pull request 'fix: rank repealed search results last' (#36) from fix/search-status-order-and-empty-snippet into main
Reviewed-on: agent/akyldash#36
2026-09-16 11:26:55 +00:00
agent c2537cf445 fix: rank repealed search results last 2026-09-16 14:07:28 +03:00
admin dffe072ce2 Merge pull request 'feat: improve legal search intent and aliases' (#35) from feature/improve-legal-search-relevance into main
Reviewed-on: agent/akyldash#35
2026-09-16 09:08:37 +00:00
agent 06c275cc40 fix: keep registration search results open 2026-09-16 10:07:39 +03:00
agent 92051184f5 fix: avoid registration routing for bank accounts 2026-09-16 10:00:33 +03:00
agent c048f20734 fix: prioritize current company registration act 2026-09-16 09:56:11 +03:00
agent af6b0b8098 feat: improve legal search intent and aliases 2026-09-16 09:48:46 +03:00
admin 94ba04ff56 Merge pull request 'fix: hide search highlight markup in snippets' (#34) from fix/plain-search-snippets into main
Reviewed-on: agent/akyldash#34
2026-09-16 06:22:34 +00:00
agent 9fb266e8ac fix: hide search highlight markup in snippets 2026-09-16 09:16:43 +03:00
admin f045233a50 Merge pull request 'feat: polish search review interface' (#33) from feature/polish-review-interface into main
Reviewed-on: agent/akyldash#33
2026-09-16 06:11:48 +00:00
agent 6a187760db fix: restore local search review flow 2026-09-16 06:54:37 +03:00
agent 90298fa6cc fix: keep tutorial spotlight visible on mobile 2026-09-16 00:26:48 +03:00
agent 503cb1f074 fix: address review findings 2026-09-16 00:20:43 +03:00
agent 4d276e0776 feat: polish search review interface 2026-09-16 00:13:48 +03:00
admin 298191173e Merge pull request 'Сохранять документы с неполными значениями справочников' (#32) from fix/unmapped-search-catalog-values into main
Reviewed-on: agent/akyldash#32
2026-09-15 21:00:24 +00:00
agent 81b085dd9d Record catalog normalization in changelog 2026-09-15 01:04:22 +03:00
agent b6ad392ab7 Handle unmapped source catalog values 2026-09-15 00:54:09 +03:00
admin bb5edf3ae2 Merge pull request 'feat(search): add legal force filter and topic taxonomy draft' (#31) from feature/search-catalog-v1 into main
Reviewed-on: agent/akyldash#31
2026-09-14 07:56:14 +00:00
agent 000cf467df docs: clarify search catalog changelog 2026-09-14 10:37:58 +03:00
agent f8f98dfe46 fix(search): validate topic hierarchy input 2026-09-14 10:36:08 +03:00
agent 5f44981bd3 fix(search): review ambiguous topic assignments 2026-09-14 10:31:45 +03:00
agent a1b4787930 feat(search): add legal force catalog and taxonomy draft 2026-09-14 10:25:38 +03:00
admin b412315ac6 Merge pull request 'docs: align relevance review with internal search' (#30) from docs/relevance-legal-review into main
Reviewed-on: agent/akyldash#30
2026-09-12 06:23:02 +00:00
agent 7e07f3ab8d docs: record internal search review workflow 2026-09-12 08:54:32 +03:00
agent 70c70fb7dd docs: clarify relevance set validation workflow 2026-09-12 08:52:55 +03:00
agent e18d477852 docs: align relevance review with internal search 2026-09-12 08:49:38 +03:00
agent 062aaa0074 docs: remove stale review example reference 2026-09-06 23:41:56 +03:00
agent 470745fce3 docs: record project status and next steps 2026-09-06 23:37:40 +03:00
admin b2274fee6f Merge pull request 'fix: harden production image builds' (#29) from fix/production-build-safety into main
Reviewed-on: agent/akyldash#29
2026-08-28 20:09:01 +00:00
agent abd244fefb fix: include search index mapping in builds 2026-08-28 22:51:12 +03:00
agent 8302c6b782 fix: harden production image builds 2026-08-28 08:59:57 +03:00
admin bbf8e7a9c8 Merge pull request 'feat: add production deployment baseline' (#28) from feature/production-deployment-baseline into main
Reviewed-on: agent/akyldash#28
2026-08-28 05:58:55 +00:00
agent bf0b6ff070 feat: add production deployment baseline 2026-08-28 08:56:12 +03:00
admin b91bc98611 Merge pull request 'feat: persist search review drafts' (#27) from feature/search-review-drafts into main
Reviewed-on: agent/akyldash#27
2026-08-28 05:13:08 +00:00
agent 4210c2493d fix: persist all review fields 2026-08-28 07:31:28 +03:00
agent 711c9e525e feat: persist search review drafts 2026-08-28 07:29:58 +03:00
admin 808ac6c792 Merge pull request 'feat: add search relevance review lab' (#26) from feature/search-review-lab into main
Reviewed-on: agent/akyldash#26
2026-08-28 04:22:46 +00:00
agent 73eb938c03 docs: record search review lab 2026-08-27 08:28:08 +03:00
agent cccae446ae fix: version search ranking snapshots 2026-08-27 08:27:23 +03:00
agent 24b3a2d422 fix: make review snapshots reproducible 2026-08-27 08:26:25 +03:00
agent f472a17897 feat: add search relevance review lab 2026-08-27 08:21:59 +03:00
admin 182b87fff2 Merge pull request 'docs: define accessible review interface' (#25) from docs/search-review-accessibility into main
Reviewed-on: agent/akyldash#25
2026-08-26 21:11:34 +00:00
agent 407dd2dc09 docs: record accessible review interface 2026-08-27 00:02:29 +03:00
agent a594aec912 fix: clarify review interface accessibility 2026-08-27 00:01:34 +03:00
agent c5f651bdae docs: define accessible review interface 2026-08-26 23:59:43 +03:00
admin f4cd453088 Merge pull request 'docs: plan search review interface' (#24) from docs/search-review-interface-plan into main
Reviewed-on: agent/akyldash#24
2026-08-26 20:31:48 +00:00
agent 57e4645a11 docs: record search review plan 2026-08-26 23:29:30 +03:00
agent 9b28616cf4 docs: secure review interface plan 2026-08-26 23:28:43 +03:00
agent e9da5a6ccc docs: plan search review interface 2026-08-26 23:27:14 +03:00
agent cb58e91d6b docs: record legal review guide 2026-08-26 01:06:57 +03:00
agent 39896c74de fix: remove placeholder link from legal guide 2026-08-26 01:06:02 +03:00
agent f3f564ab08 docs: add legal review guide 2026-08-26 01:03:34 +03:00
agent ccbb2f0944 fix: rebuild legal review workbooks 2026-08-26 00:39:14 +03:00
agent d468c869c0 docs: describe legal review workbooks 2026-08-26 00:31:28 +03:00
agent 47e03bbc4d fix: neutralize legal review example 2026-08-26 00:30:27 +03:00
agent eafc06f0bc docs: add language-specific legal review workbooks 2026-08-26 00:28:34 +03:00
agent 9dcbcad9aa docs: record legal review form 2026-08-25 13:37:15 +03:00
agent 028062bd14 docs: add legal relevance review form 2026-08-25 13:35:41 +03:00
admin bb69ea54b7 Merge pull request 'feat: add versioned search API contract' (#17) from feature/search-api-contract into main
Reviewed-on: agent/akyldash#17
2026-08-25 05:35:54 +00:00
agent 0f309f34fa docs: update changelog for search API 2026-08-25 08:32:21 +03:00
agent 2ef33c5cc3 fix: use stable search catalog codes 2026-08-25 08:26:48 +03:00
agent 0ef6ad4d20 docs: define search catalogs v1 2026-08-25 08:22:07 +03:00
agent bc3951449a docs: align backend version 2026-08-25 07:35:13 +03:00
agent 9b629d93e3 fix: complete search API contract 2026-08-25 07:29:06 +03:00
agent 0bef90debd fix: restrict search to current editions 2026-08-21 07:24:16 +03:00
agent 73ff287b20 feat: add search API contract 2026-08-21 06:12:47 +03:00
admin 8f47dbb4f0 Merge pull request 'feat: atomically switch active search index alias' (#16) from feature/opensearch-alias-switch into main
Reviewed-on: agent/akyldash#16
2026-08-21 03:06:45 +00:00
210 changed files with 52155 additions and 154 deletions

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
.git
.gitignore
.env
.env.*
*.json
!backend/
!backend/search/
!backend/search/*.json
*.xlsx
*.pdf
*.jpg
data/
docs/
tools/
__pycache__/
*.pyc
+1
View File
@@ -0,0 +1 @@
FROM caddy:2.11.6-alpine@sha256:881bbc60f9986d5ab8e7cfd6cf7e4ef3c9c0439fef2429d035d065577882f028
+16
View File
@@ -0,0 +1,16 @@
name: Validate Caddyfile
description: Run the pinned Caddy validator against the checked-out Caddyfile
inputs:
workspace:
description: Repository workspace path supplied by the workflow
required: true
runs:
using: docker
image: Dockerfile
entrypoint: /usr/bin/caddy
args:
- validate
- --config
- ${{ inputs.workspace }}/deploy/production/Caddyfile
- --adapter
- caddyfile
+1
View File
@@ -0,0 +1 @@
FROM ghcr.io/osscontainertools/kaniko:v1.28.5-debug@sha256:c73ca67a3b494bce79a8fb6090c4f73c18997ab272d80bffc460a62343aeb412
+22
View File
@@ -0,0 +1,22 @@
name: Build image with Kaniko in a rootless container
description: Validate a Dockerfile by building it without publishing the image or cache
inputs:
workspace:
description: Repository workspace path supplied by the workflow
required: true
dockerfile:
description: Dockerfile path relative to the checked-out repository
required: true
destination:
description: Image reference required by Kaniko; the image is not pushed
required: true
runs:
using: docker
image: Dockerfile
entrypoint: /kaniko/executor
args:
- --context=dir://${{ inputs.workspace }}
- --dockerfile=${{ inputs.workspace }}/${{ inputs.dockerfile }}
- --destination=${{ inputs.destination }}
- --no-push
- --no-push-cache
+4
View File
@@ -0,0 +1,4 @@
FROM ghcr.io/osscontainertools/kaniko:v1.28.5-debug@sha256:c73ca67a3b494bce79a8fb6090c4f73c18997ab272d80bffc460a62343aeb412
COPY release-build.sh /usr/local/bin/release-build
COPY validate-destination.sh /usr/local/bin/validate-destination.sh
ENTRYPOINT ["/busybox/sh", "/usr/local/bin/release-build"]
+23
View File
@@ -0,0 +1,23 @@
name: Publish a pinned Akyldash image
description: Build and push one commit-tagged image with pinned rootless Kaniko
inputs:
workspace:
description: Checked out source directory
required: true
dockerfile:
description: Dockerfile path relative to workspace
required: true
destination:
description: Commit-specific OCI tag
required: true
digest_file:
description: Workspace path for Kaniko's resulting digest
required: true
runs:
using: docker
image: Dockerfile
args:
- ${{ inputs.workspace }}
- ${{ inputs.dockerfile }}
- ${{ inputs.destination }}
- ${{ inputs.digest_file }}
@@ -0,0 +1,22 @@
#!/busybox/sh
set -eu
workspace=$1
dockerfile=$2
destination=$3
digest_file=$4
: "${REGISTRY_USERNAME:?registry username secret is required}"
: "${REGISTRY_PASSWORD:?registry password secret is required}"
/busybox/sh /usr/local/bin/validate-destination.sh "$dockerfile" "$destination"
mkdir -p /kaniko/.docker "$(dirname "$digest_file")"
auth=$(printf '%s:%s' "$REGISTRY_USERNAME" "$REGISTRY_PASSWORD" | base64 | tr -d '\n')
umask 077
printf '{"auths":{"git.vakanaut.ru":{"auth":"%s"}}}\n' "$auth" > /kaniko/.docker/config.json
unset auth REGISTRY_USERNAME REGISTRY_PASSWORD
/kaniko/executor \
--context="dir://${workspace}" \
--dockerfile="${workspace}/${dockerfile}" \
--destination="$destination" \
--digest-file="$digest_file" \
--no-push-cache
@@ -0,0 +1,49 @@
#!/bin/sh
set -eu
if [ "$#" -ne 2 ]; then
echo 'ERROR: expected Dockerfile path and image destination' >&2
exit 2
fi
dockerfile=$1
destination=$2
case "$dockerfile" in
deploy/production/Dockerfile.backend)
repository=git.vakanaut.ru/admin/akyldash/backend
;;
deploy/production/Dockerfile.opensearch)
repository=git.vakanaut.ru/admin/akyldash/opensearch
;;
*)
echo 'ERROR: Dockerfile is outside the approved production image set' >&2
exit 2
;;
esac
case "$destination" in
"$repository":*) tag=${destination#"$repository":} ;;
*)
echo 'ERROR: destination is outside the approved registry repository' >&2
exit 2
;;
esac
commit=${tag%%-*}
run_id=${tag#*-}
case "$commit" in
''|*[!0123456789abcdef]*)
echo 'ERROR: destination commit must be a lowercase Git SHA-1' >&2
exit 2
;;
esac
if [ "${#commit}" -ne 40 ] || [ "$run_id" = "$tag" ]; then
echo 'ERROR: destination tag must contain a 40-character commit and run ID' >&2
exit 2
fi
case "$run_id" in
''|*[!0123456789]*)
echo 'ERROR: destination run ID must be numeric' >&2
exit 2
;;
esac
+95
View File
@@ -0,0 +1,95 @@
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
tests-and-images:
runs-on: akyldash-ci
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Run backend tests
run: PYTHONPATH=backend python3 -m unittest discover -s backend -p 'test_*.py' -v
- name: Run Telegram bot tests
run: python3 -m unittest discover -s tools/telegram-bot -p 'test_*.py' -v
- name: Run infrastructure backup tests
run: python3 -m unittest discover -s infra/backup -p 'test_*.py' -v
- name: Check root guard UID decisions
run: bash infra/bootstrap/test-root-guard.sh
- name: Check recovery host guard
run: bash infra/bootstrap/test-host-guard.sh
- name: Check verifier helper argument guard
run: bash infra/bootstrap/test-verify-helper.sh
- name: Check host audit root guard
run: bash infra/bootstrap/test-audit-host.sh
- name: Check Compose helper allowlist
run: bash infra/deploy/test-compose-helper.sh
- name: Check production config root guard
run: bash infra/deploy/test-production-config-guard.sh
- name: Check runner config and isolation guards
run: |
bash infra/runner/test-runner-config.sh
bash infra/runner/test-install-runner-idempotency.sh
bash infra/runner/test-install-runner-guard.sh
- name: Check CI container operation isolation
run: bash infra/runner/test-ci-container-builds.sh
- name: Check infrastructure shell syntax
run: |
bash -n infra/bootstrap/bootstrap-ubuntu-24.04.sh infra/bootstrap/host-guard.sh \
infra/bootstrap/verify.sh infra/bootstrap/verify-helper.sh infra/bootstrap/audit-host.sh \
infra/deploy/install-helper.sh infra/deploy/compose-helper.sh infra/deploy/test-compose-helper.sh \
infra/deploy/prepare-production-config.sh infra/deploy/test-production-config-guard.sh \
.gitea/actions/kaniko-push/release-build.sh \
infra/bootstrap/test-audit-host.sh infra/bootstrap/test-root-guard.sh \
infra/bootstrap/test-verify-helper.sh infra/test/run-as-unprivileged.sh \
infra/runner/install-runner.sh infra/runner/install-runner-lib.sh \
infra/runner/act-runner-healthcheck.sh infra/runner/test-runner-config.sh \
infra/runner/test-install-runner-idempotency.sh infra/runner/test-install-runner-guard.sh \
infra/runner/test-ci-container-builds.sh
python3 -c "import ast,pathlib; ast.parse(pathlib.Path('infra/runner/secure-runner-image.py').read_text())"
python3 -c "import ast,pathlib; ast.parse(pathlib.Path('infra/deploy/validate_release.py').read_text())"
python3 -c "import ast,pathlib; ast.parse(pathlib.Path('infra/deploy/deploy_release.py').read_text())"
python3 -c "import ast,pathlib; ast.parse(pathlib.Path('infra/deploy/create_release_manifest.py').read_text())"
python3 -c "import ast,pathlib; ast.parse(pathlib.Path('infra/deploy/release_handoff.py').read_text())"
python3 -c "import ast,pathlib; ast.parse(pathlib.Path('infra/deploy/release_refs.py').read_text())"
python3 -c "import ast,pathlib; ast.parse(pathlib.Path('infra/deploy/release_state.py').read_text())"
python3 -c "import ast,pathlib; ast.parse(pathlib.Path('infra/deploy/deployment_lock.py').read_text())"
- name: Run deployment contract and rollback tests
run: python3 -m unittest discover -s infra/deploy -p 'test_*.py' -v
- name: Validate production Compose and Caddyfile
run: |
secret_file=$(mktemp)
trap 'rm -f "$secret_file"' EXIT
REVIEW_SECRET_FILE="$secret_file" docker compose --env-file deploy/production/.env.example -f deploy/production/compose.yaml config --quiet
- name: Validate Caddyfile in isolated container action
uses: ./.gitea/actions/caddy-validate
with:
workspace: ${{ github.workspace }}
- name: Build production images
uses: ./.gitea/actions/kaniko-build
with:
workspace: ${{ github.workspace }}
dockerfile: deploy/production/Dockerfile.backend
destination: akyldash-backend:ci
- name: Build OpenSearch production image
uses: ./.gitea/actions/kaniko-build
with:
workspace: ${{ github.workspace }}
dockerfile: deploy/production/Dockerfile.opensearch
destination: akyldash-opensearch:ci
- name: Prepare a synthetic artifact compatibility marker
run: |
mkdir -p artifact-smoke
printf 'Gitea artifact upload smoke test; no release data.\n' > artifact-smoke/marker.txt
- name: Verify Gitea runtime artifact upload
uses: https://gitea.com/actions/gitea-upload-artifact@055b8b3f04a4a7ed853f2c6ab04256f83e4874dc
with:
name: artifact-smoke-${{ github.run_id }}
path: artifact-smoke/marker.txt
if-no-files-found: error
retention-days: 1
+64
View File
@@ -0,0 +1,64 @@
name: Production handoff status (not deployed)
on:
workflow_run:
workflows: ["Release production images"]
branches: [main]
types: [completed]
jobs:
deployment-status:
name: NOT DEPLOYED - handoff status only
if: >-
gitea.event.workflow_run.conclusion == 'success' &&
gitea.event.workflow_run.event == 'workflow_run' &&
gitea.event.workflow_run.head_branch == 'main'
runs-on: akyldash-ci
steps:
- uses: actions/checkout@v4
with:
ref: main
- name: Verify source release identity before artifact lookup
env:
SOURCE_WORKFLOW: ${{ gitea.event.workflow.name }}
SOURCE_WORKFLOW_PATH: ${{ gitea.event.workflow_run.path }}
SOURCE_EVENT: ${{ gitea.event.workflow_run.event }}
SOURCE_CONCLUSION: ${{ gitea.event.workflow_run.conclusion }}
SOURCE_BRANCH: ${{ gitea.event.workflow_run.head_branch }}
SOURCE_RUN_ID: ${{ gitea.event.workflow_run.id }}
SOURCE_SHA: ${{ gitea.event.workflow_run.head_sha }}
SOURCE_REPOSITORY: ${{ gitea.event.repository.full_name }}
run: python3 infra/deploy/release_handoff.py validate-handoff-source
- name: Read artifact metadata with the installed Gitea helper when available
env:
GITEA_URL: ${{ gitea.server_url }}
GITEA_TOKEN: ${{ gitea.token }}
SOURCE_RUN_ID: ${{ gitea.event.workflow_run.id }}
SOURCE_WORKFLOW: ${{ gitea.event.workflow.name }}
SOURCE_WORKFLOW_PATH: ${{ gitea.event.workflow_run.path }}
SOURCE_EVENT: ${{ gitea.event.workflow_run.event }}
SOURCE_CONCLUSION: ${{ gitea.event.workflow_run.conclusion }}
SOURCE_BRANCH: ${{ gitea.event.workflow_run.head_branch }}
SOURCE_SHA: ${{ gitea.event.workflow_run.head_sha }}
SOURCE_REPOSITORY: ${{ gitea.event.repository.full_name }}
run: |
if ! command -v gitea-api >/dev/null 2>&1; then
echo 'DRY-RUN only: gitea-api is unavailable in this job; artifact metadata and host handoff are not verified.'
python3 infra/deploy/release_handoff.py dry-run
exit 0
fi
if [[ -z ${GITEA_TOKEN:-} ]]; then
echo 'DRY-RUN only: GITEA_TOKEN is unavailable; artifact metadata and host handoff are not verified.'
python3 infra/deploy/release_handoff.py dry-run
exit 0
fi
metadata=$(mktemp)
trap 'rm -f "$metadata"' EXIT
gitea-api GET "/repos/admin/akyldash/actions/runs/${SOURCE_RUN_ID}/artifacts" > "$metadata"
python3 infra/deploy/release_handoff.py dry-run --artifact-metadata "$metadata"
- name: State that no deployment was performed
run: |
echo 'DEPLOYMENT STATUS: NOT DEPLOYED'
echo 'This workflow checks source provenance and may inspect artifact metadata only.'
echo 'It does not download the descriptor ZIP, open SSH, or invoke the Windows receiver.'
echo 'No Windows execution runner or automatic runtime binding has been verified.'
+90
View File
@@ -0,0 +1,90 @@
name: Release production images
on:
workflow_run:
workflows: ["CI"]
branches: [main]
types: [completed]
jobs:
publish-images:
if: >-
gitea.event.workflow_run.conclusion == 'success' &&
gitea.event.workflow_run.event == 'push' &&
gitea.event.workflow_run.head_branch == 'main'
runs-on: akyldash-ci
steps:
- uses: actions/checkout@v4
with:
ref: ${{ gitea.event.workflow_run.head_sha }}
- name: Verify successful CI source before using registry credentials
env:
SOURCE_WORKFLOW: ${{ gitea.event.workflow.name }}
SOURCE_WORKFLOW_PATH: ${{ gitea.event.workflow_run.path }}
SOURCE_EVENT: ${{ gitea.event.workflow_run.event }}
SOURCE_CONCLUSION: ${{ gitea.event.workflow_run.conclusion }}
SOURCE_BRANCH: ${{ gitea.event.workflow_run.head_branch }}
SOURCE_RUN_ID: ${{ gitea.event.workflow_run.id }}
SOURCE_SHA: ${{ gitea.event.workflow_run.head_sha }}
SOURCE_REPOSITORY: ${{ gitea.event.repository.full_name }}
run: python3 infra/deploy/release_handoff.py validate-ci-source
- name: Require explicitly configured Gitea registry credentials
env:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: |
test -n "$REGISTRY_USERNAME" || { echo 'REGISTRY_USERNAME repository secret is required'; exit 1; }
test -n "$REGISTRY_PASSWORD" || { echo 'REGISTRY_PASSWORD repository secret is required'; exit 1; }
- name: Build and publish backend image
uses: ./.gitea/actions/kaniko-push
env:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
with:
workspace: ${{ github.workspace }}
dockerfile: deploy/production/Dockerfile.backend
destination: git.vakanaut.ru/admin/akyldash/backend:${{ gitea.event.workflow_run.head_sha }}-${{ gitea.run_id }}
digest_file: ${{ github.workspace }}/release/backend.digest
- name: Build and publish OpenSearch image
uses: ./.gitea/actions/kaniko-push
env:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
with:
workspace: ${{ github.workspace }}
dockerfile: deploy/production/Dockerfile.opensearch
destination: git.vakanaut.ru/admin/akyldash/opensearch:${{ gitea.event.workflow_run.head_sha }}-${{ gitea.run_id }}
digest_file: ${{ github.workspace }}/release/opensearch.digest
- name: Create and validate release descriptor
run: |
python3 infra/deploy/create_release_manifest.py \
--repository admin/akyldash \
--commit '${{ gitea.event.workflow_run.head_sha }}' \
--run-id '${{ gitea.run_id }}' \
--source-run-id '${{ gitea.event.workflow_run.id }}' \
--backend-digest release/backend.digest \
--opensearch-digest release/opensearch.digest \
--output release/release-manifest.json
python3 infra/deploy/validate_release.py release/release-manifest.json \
--policy infra/deploy/release-policy.json
(cd release && sha256sum release-manifest.json > release-manifest.json.sha256)
- id: descriptor
name: Record release descriptor digest
run: |
(cd release && sha256sum -c release-manifest.json.sha256)
digest=$(sha256sum release/release-manifest.json)
digest=${digest%% *}
printf 'descriptor_sha256=%s\n' "$digest" >> "$GITEA_OUTPUT"
- name: Upload verified release descriptor
uses: https://gitea.com/actions/gitea-upload-artifact@055b8b3f04a4a7ed853f2c6ab04256f83e4874dc
with:
name: release-descriptor-${{ gitea.run_id }}-${{ steps.descriptor.outputs.descriptor_sha256 }}
path: |
release/release-manifest.json
release/release-manifest.json.sha256
if-no-files-found: error
retention-days: 90
- name: Confirm uploaded release descriptor
run: |
(cd release && sha256sum -c release-manifest.json.sha256)
echo 'Release descriptor and checksum are available as this run artifact.'
+24
View File
@@ -0,0 +1,24 @@
name: Runner smoke test
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
jobs:
runner-smoke:
runs-on: akyldash-ci
steps:
- name: Verify isolated runner boundary
run: |
host_uid=$(awk 'NR == 1 {print $2}' /proc/self/uid_map)
test "$host_uid" -gt 0
test ! -S /var/run/docker.sock
test ! -e /run/user/1000/docker.sock
test -z "${DOCKER_HOST:-}"
test ! -e /etc/akyldash/production.env
test ! -e /etc/akyldash/review_secret
test ! -e /opt/akyldash/current
printf 'Isolated runner: version=%s host=%s uid-map=%s\n' \
"$GITEA_ACTIONS_RUNNER_VERSION" "$(hostname)" "$host_uid"
+6
View File
@@ -1,10 +1,12 @@
# Local credentials and secrets
credentials.json
codex.json
# Python/runtime artifacts (for the upcoming bot implementation)
__pycache__/
.venv/
*.py[cod]
tools/frontend-visual-review/node_modules/
# Local environment files
.env
@@ -16,3 +18,7 @@ data/
# Local runtime logs
logs/
# Local generated reports; deliberately publish reviewed/sanitized evidence with git add -f
/reports/
/tmp/
+35
View File
@@ -0,0 +1,35 @@
{
"detector": {
"ignoreRules": [],
"ignoreFiles": [],
"ignoreValues": [
{
"rule": "cream-palette",
"value": "*",
"files": [
"backend/search/review.html"
],
"createdAt": "2026-10-11T15:40:02.635Z",
"reason": "Agent: DESIGN.md explicitly approves the #F7F2E8 felt-inspired bg token for this portal; current page background matches that user-approved palette"
},
{
"rule": "cramped-padding",
"value": "*",
"files": [
"backend/search/review.html"
],
"createdAt": "2026-10-11T15:41:28.434Z",
"reason": "Agent: current page uses 12–24px padding in bordered containers; the prior stop finding has no element detail and does not reproduce in the current detector"
},
{
"rule": "design-system-font",
"value": "sans",
"files": [
"backend/search/review.html"
],
"createdAt": "2026-10-11T15:41:33.020Z",
"reason": "Agent: DESIGN.md specifies Noto Sans with sans-serif fallback; this finding is the inherited generic fallback"
}
]
}
}
+124
View File
@@ -0,0 +1,124 @@
# Акылдаш — инструкции для агента
Проект: юридическая информационно-аналитическая платформа; Search API на
Python, SQLite и OpenSearch. На 2026-10-25 публичный `/review` и прямой origin
отвечают HTTP 200; состав production-контейнеров, готовность индекса и
проверенность backup/restore не подтверждены. Сверяй актуальное состояние с
[`CURRENT_STATE.md`](docs/operations/CURRENT_STATE.md), не считай HTTP 200
доказательством готовности поиска.
- Начинай с [инфраструктурного состояния](docs/operations/CURRENT_STATE.md) и
[правил доступа](docs/operations/ACCESS.md). Не запрашивай доступы повторно.
- Архитектура и процедуры: [инфраструктура](docs/operations/INFRASTRUCTURE.md),
[деплой](docs/operations/DEPLOYMENT.md), [backup](docs/operations/BACKUP.md),
[rollback](docs/operations/ROLLBACK.md), [восстановление](docs/operations/RECOVERY.md).
- Работай в feature/fix/refactor-ветке. `main` защищена; изменения вноси через
PR. DEV не одобряет и не сливает собственный PR. Условный merge выполняет
только REVIEWER по правилам раздела «Автономный workflow PR» ниже. Сохраняй
незакоммиченные изменения.
- SSH alias — `akyldash_vps`; используй только read-only диагностику. Не давай
`codex` общий sudo или доступ к Docker socket. Root-действия требует владелец.
Для Gitea используй `gitea-api`, не читай файлы с учётными данными.
- Репозиторный Gitea Actions runner описан в
[CI_RUNNER.md](docs/operations/CI_RUNNER.md). Регистрация выполняется
владельцем через Settings → Actions → Runners для `admin/akyldash`; токен
вводится интерактивно и не попадает в Git. Runner использует отдельный
rootless Docker daemon; job-контейнеры не получают Docker socket или
production secrets. Не включай production deployment из CI без отдельного
проверенного deploy-helper.
- Локальные проверки: `PYTHONPATH=backend python3 -m unittest discover -s backend -p 'test_*.py' -v` и
`python3 -m unittest discover -s tools/telegram-bot -p 'test_*.py' -v`,
`python3 -m unittest discover -s infra/backup -p 'test_*.py' -v` и
`python3 -m unittest discover -s infra/deploy -p 'test_*.py' -v`,
`bash infra/bootstrap/test-host-guard.sh`.
Production images собираются командами из `.gitea/workflows/ci.yaml`.
- Перед изменениями production необходим проверенный backup. Формального
механизма миграций нет; проверяй совместимость SQLite/OpenSearch. Не меняй
SSH, firewall или production вручную без владельца и последующего PR.
- Холодный backup требует согласованного окна простоя; восстановление допускается
только в новый staging-каталог, пока владелец отдельно не проверил процедуру
для нового хоста.
- После каждого изменения инфраструктуры обновляй
`docs/operations/CURRENT_STATE.md` по фактическим проверкам.
## Delivery First: проверка по уровню риска
- Перед работой укажи в PR уровень `LOW`, `MEDIUM` или `HIGH`; при сомнении
выбери более высокий. Уровень добавляет нужную проверку, но никогда не
отменяет CI, одобрение или защиту `main`.
- Каждый PR сохраняет текущие проверки Gitea Actions: job IDs
`tests-and-images` и `runner-smoke`. Не переименовывай и не обходи их, не
удаляй тесты или проверки безопасности ради скорости.
- `LOW`: документация, комментарии и тексты без изменения исполняемого
поведения или контракта; проверяй diff и актуальность утверждений, затем
дождись обычного CI.
- `MEDIUM`: изменение поведения в одном компоненте; добавь регрессионный тест,
выполни целевые проверки и обычный CI.
- `HIGH`: доступ/авторизация, данные и ingestion, схемы/хранилища,
backup/restore/recovery, production config, секреты, runner/CI, безопасность
или deploy; выполни целевые тесты вместе с обычным CI и приложи проверяемое
обоснование угроз, совместимости и rollback. Production-действия остаются
за владельцем.
- Ревью блокирует только конкретный воспроизводимый дефект, нарушение границы
доступа/безопасности, несовместимость данных или контракта, пропущенную
обязательную проверку либо отсутствие безопасного rollback. Идеи и стилистические
замечания перечисляй отдельно как неблокирующие.
- Полная процедура, метрики, Gitea-ограничения и проверенные временные бюджеты:
[DELIVERY_POLICY.md](docs/development/DELIVERY_POLICY.md).
## Автономный workflow PR
- Маршруты ниже указаны из контекста оркестратора/root: оркестратор — thread
`01a144a5-8b95-77c3-b311-9a9afd062ca8`, host `local`; DEV — thread
`01a138f1-3b34-7c91-a9ed-4b43c3c85f1f`, host
`remote-ssh-codex-managed:712b8d4e-24e1-4dd5-92af-85d1fbd26449`, Gitea
`codex` (ID 6); REVIEWER — thread `01a144f9-5b51-7923-8398-61d4a6a98306`,
host `remote-ssh-codex-managed:b61555d7-8d1a-4cfa-988b-c8da4791d426`, Gitea
`agent` (ID 7). Если этот же чат отображает REVIEWER как `local` внутри его
собственного app-server, это self-label и не является маршрутом из контекста
оркестратора.
- DEV принимает только явно зарегистрированную оркестратором человеческую
задачу с полями `task`, `repo`, `branch`, `pr`, `head_sha`, `phase`. Репозиторий
должен быть `admin/akyldash`; работай только в назначенной ветке и PR. Веди
одну задачу/PR за раз и не подхватывай старые PR самостоятельно.
- Допустимые фазы: `initial_review` — реализация и устранение review findings;
`final_review_and_merge` — только после первоначального успешного review,
changelog-коммита и отдельного назначения оркестратора. Не переходи к следующей
фазе без назначения. Если задача приостановлена, останови любые действия;
после resume перепроверь назначение, PR, head/base, checks и review с нуля.
- Повторная доставка одного назначения идемпотентна по ключу
`task`+`pr`+`head_sha`+`stage`: не дублируй commit, push, review-запрос или
merge; сообщи оркестратору о уже выполненном результате. Новый head или stage
образует новое назначение и требует повторной проверки.
- Межчатовое чтение недоступно: оркестратор через central relay сам читает
результаты и выдаёт назначения. Завершай каждый этап отчётом с полями `task`,
`repo`, `branch`, `pr`, `head_sha`, `stage`, `checks`, `findings`,
`next_action`. Не настраивай отдельные webhook, daemon или транспорт.
- DEV исследует, изменяет, проверяет, коммитит и отправляет изменения; REVIEWER
не меняет код и независимо проверяет полный diff от base после каждого push,
включая предыдущие findings. DEV не одобряет и не сливает свой PR.
- Только после первоначального успешного формального review DEV добавляет
значимые изменения в существующий changelog, коммитит и отправляет их в тот же
PR. Затем REVIEWER полностью проверяет итоговый diff. Для docs-only задачи не
придумывай изменения кода; правила версии применяй только если они явно
относятся к этой документационной правке.
- Слияние может выполнить только REVIEWER для зарегистрированной человеческой
задачи, в фазе `final_review_and_merge`, после явного назначения оркестратора.
Непосредственно перед ним REVIEWER подтверждает `admin/akyldash`, PR, base
`main`, ожидаемую ветку, автора PR `codex` (ID 6), неизменность base и head,
причём head должен совпадать с зарегистрированным `head_sha`, включая
`head_commit_id` для merge-запроса. Требуются все успешные required CI/test
checks и gates, отсутствие pending/failed/unknown checks, открытых
findings и активных `REQUEST_CHANGES`, плюс актуальный формальный `APPROVED`
от REVIEWER для точного текущего commit с параметрами `commit_id == head_sha`,
`stale == false`, `dismissed == false`. При изменении head или base review и
проверки выполняются заново. Комментарий с approval не является формальным
review.
Используй только обычный server-side merge с `head_commit_id`; не используй
`force_merge` и не обходи branch protection. Если любой существенный gate,
статус или защита неизвестны либо не проверяются — не сливай PR и сообщи
оркестратору, что именно осталось неизвестным.
- Все Gitea API-вызовы выполняй только через `gitea-api` с личным окружением;
Git используй с обычным credential helper. Не читай credentials из файлов
репозитория. Запускай Git- и API-команды отдельно, без объединения с другими
командами.
+165
View File
@@ -2,6 +2,171 @@
## Не выпущено
- Deployment: подготовлен внешний handoff для Windows-клиента от точного Gitea
commit. Bundle фиксирует commit/tree/blob identity и SHA-256; installer
проверяет bundle и ACL до записи и не запускает Git. Bootstrap на release A
может подготовить release B только при точном совпадении pinned runtime-файлов
и release policy. Первый Windows task остаётся preparation-only; отдельные
auto-apply config и task выключены. Изолированный PowerShell 5.1 harness для
bundle-проверки завершился успешно на точном PR SHA; реальная установка,
регистрация задач, SSH и production не выполнялись и не активировались.
- Infrastructure: Windows release installer и external receiver читают владельцев
и ACL ACE напрямую как SID, без преобразования в имена учётных записей. Это
устраняет зависимость проверки от разрешения имён; неизвестные SID остаются
видимыми и отклоняются существующими fail-closed guard-политиками. Проверены
synthetic unmapped-SID fixtures и read-only ACL preflight; production ACL не
менялись.
- Документация: объединены продуктовый, UI- и deployment-планы; уточнены
внешняя backup-retention политика и обязательный owner-approved maintenance
window для cold backup. Передача корпуса требует атомарной публикации
проверенного staging в `DATA_ROOT`; безопасное возобновление либо fail-closed
при прерывании остаётся release gate. Механизм атомарной публикации ещё не
реализован.
- Backend `0.13.4` / Frontend `0.3.4`, алгоритм поиска `search-8`: для явно
заданного multi-index поиска включена глобальная статистика DFS; signed review
snapshots сохраняют полный index scope, а пустую выдачу можно сохранить с
обязательным общим комментарием и точной проверкой рангов. Узкое расширение
термина аренды сохранено без глобальных весов. На executable SHA
`1c7ddf2` выполнен полный isolated прогон 7 500 запросов: изменились 537
порядков относительно исходного baseline, 6 выдач остались пустыми; это
наблюдения ранжирования, не экспертная оценка права. Три актуальных акта
проверялись только в отдельном локальном supplement; production и исходный
индекс не менялись.
- Успешный CI на `push` в `main` запускает release workflow: при настроенных
registry secrets он собирает образы rootless Kaniko, публикует digest
references и сохраняет schema-v4 descriptor с SHA-256 в Gitea artifact;
sidecar проверяется после извлечения из корня. Следующий workflow проверяет
source-run identity и, при доступном `gitea-api`, метаданные артефакта только
в dry-run и явно сообщает `NOT DEPLOYED`: ZIP не скачивается, SSH и host
apply не выполняются. Добавлена отдельная root-verified status→fixed-tuple→
Windows `--once` привязка через строгий SSH dispatcher; она не использует
Gitea/PR runner и не требует Gitea token на Windows. Config и Task Scheduler
template выключены, задача не зарегистрирована и production CD не активирован.
Зелёный handoff не означает деплой. Подготовлены owner-run cutover и rollback с
проверкой backup, image IDs, RU/KY canaries и recovery journal; CI проверяет
synthetic artifact и завершается ошибкой при отсутствии файлов. Windows
receiver прошёл 17 synthetic client checks, 8 protocol checks и проверку
private owner ACL на disposable real-NTFS fixture. Inert Windows argv probe
подтвердил передачу пути скрипта с пробелами; Task Scheduler COM проверил
XML с `TASK_VALIDATE_ONLY` — задачу не регистрировали, не обновляли и не
запускали. Production D: path, SSH, установка на VPS и полный backup/restore
не проверены; cutover остаётся заблокирован до отдельного restore-теста и
owner bootstrap. Принимаются только завершённые успешные Gitea runs; pending
recovery journal записывается до остановки
сервисов, повторно применённый release отклоняется до простоя, а проверка
`main` использует явно заданный production checkout. Windows preflight
проверяет owner ACL и требует свободное место под максимальный допустимый
архив.
- Frontend `0.3.3` / Backend `0.13.2`: управление пользователями отображается
согласно правам аккаунта, подписи ролей локализованы, повторяющиеся действия
кабинета убраны, а знак лаборатории приведён к основному бренду.
- Добавлена политика Delivery First по уровням `LOW`/`MEDIUM`/`HIGH`: описаны
обязательная глубина проверок, регрессионные тесты и доказательства угроз,
совместимости и отката; обычные CI, одобрение и защита `main` остаются
обязательными.
- Frontend `0.3.2`: устранены пересечения названия бренда с поиском и
мобильной навигации документа; навигационные ссылки получили общие quiet и
primary состояния.
- Frontend `0.3.1`: уточнена компоновка поиска, лаборатории и кабинета;
splitter сохраняет минимальную ширину панелей, а на средних экранах фильтры
открываются доступной боковой панелью. Исправлено отображение футера на
маршруте пользователей и browser fixture смены пароля.
- Frontend `0.3.0` / Backend `0.13.1`: добавлен доступный splitter для
изменения ширины результатов и документа. Язык поиска находится в фильтрах;
состояния открытия, оглавление правовых заголовков, лаборатория и кабинет
приведены к последовательной и компактной компоновке.
- Добавлен план развития платформы со сравнением возможностей «Токтом Онлайн»,
пробелов Акылдаша и этапов продуктовой проверки.
- Frontend `0.2.6` / Backend `0.13.1`: двухпанельная компоновка поиска
сохраняется до ширины 1175 px, чтобы три панели не обрезались при
промежуточных ширинах окна.
- Frontend `0.2.5` / Backend `0.13.1`: устранены промежуточные переполнения
навигации, старый текст больше не остаётся при ошибке загрузки другого
документа; очищаются дополнительные URL-атрибуты юридической разметки,
включая `srcset` и `ping`. Добавлены fixture-проверки ошибок и гонок загрузки,
изоляции ссылок, reflow и безопасных форм управления пользователями.
- Frontend `0.2.4` / Backend `0.13.1`: исправлены состояния и адаптивный поиск,
чтение документа и лаборатории, доступные диалоги; управление пользователями
вынесено на отдельный защищённый экран. Документная разметка очищается от
активного HTML и небезопасных ссылок. Добавлены проверки маршрутов и reflow;
правовой текст и API управления пользователями не менялись.
- Frontend `0.2.3`: добавлен публичный поиск по правовой базе с фильтрами,
адаптивной выдачей, просмотром редакций и поиском по тексту документа;
внутренняя лаборатория сохранена, её подсказки стали компактнее и привязаны
к целевым элементам. Интерфейс использует локальные шрифты Noto Sans и Noto
Serif.
- Исправлены переключение на доступный язык редакции, очистка содержимого и
ссылки на официальный источник при смене документа или ошибке загрузки;
подписи фильтров локализуются на кыргызский язык.
- Infrastructure: systemd unit Gitea Actions runner теперь использует
`ProtectHome=read-only`, сохраняя доступность `/run/user` и существующий
writable carve-out для rootless runtime; VPS не изменялся.
- Backend `0.12.1`: добавлен ограниченный возобновляемый staging редакций из
текущего API Минюста с проверкой точных ID и метаданных, изоляцией от
legacy-корпуса и сохранением исходного порядка редакций при нормализации и
экспорте. Тест overlap guard задаёт безопасные права fixture независимо от
`umask` CI; сам guard не ослаблен.
- Rollback preflight проверяет digest-pinned image references по сохранённому
image ID и останавливается до изменений deployment; переключение production
на digest и полноценный digest rollback пока не реализованы.
- Infrastructure: добавлен офлайн-контракт release manifest с точной схемой,
digest-ссылками и repository allowlist; проверен Compose override с
`--no-build` как требованием будущего deploy. Публикация, provenance и
production-деплой не включены.
- Добавлен read-only возобновляемый inventory корпуса с полным хешированием,
потоковой сверкой, индексируемыми диапазонами путей и provenance SHA-256
проверяющего; значения `stat` хранятся без ограничения SQLite INTEGER.
Сравнение source/staging сверяет путь, размер и SHA-256, удерживает shared
locks и отклоняет пустые корпуса без документов или фрагментов. Runbook и
`CURRENT_STATE.md` отражают, что полный аудит корпуса ещё выполняется; это
изменение не означает готовность корпуса или production.
- CI actions теперь используют workspace, переданный Gitea, для Caddyfile и Kaniko build context; CI run 69 и smoke run 70 прошли на self-hosted runner.
- Runner installer сохраняет `HOME` и XDG-пути в user-unit `docker.service`, перезапускает активный daemon и проверяет окружение его процесса.
- Runner installer защищает image и mountpoint root-owned home-каталогом, оставляет регистрацию и workspace в runner-owned каталоге, безопасно форматирует образ по открытому FD и проверяет `nosuid,nodev`.
- Backend `0.11.3`: ключ подписи поисковых снимков читается из Docker secret-файла, а не из аргументов процесса. Добавлена базовая инфраструктура VPS, Caddy HTTPS proxy с разделёнными Compose-сетями и CI-проверка конфигурации; публикация образов и production-деплой остаются выключены до проверки runner, registry, backup/restore и ограниченного deploy-helper.
- Frontend `0.1.2`: на планшетной ширине экран поиска переходит в одну колонку, чтобы фильтры, результаты и документ помещались в окно.
- Backend `0.11.2`: смена пароля требует текущий пароль и запрещает оставить прежний, включая временный пароль при первом входе.
- Backend `0.11.1` и frontend `0.1.1`: в управлении пользователями показываются даты первого и последнего входа, а также последнего сброса пароля.
- Backend `0.11.0` и frontend `0.1.0`: лаборатория переоформлена по дизайн-системе «Түндүк»; добавлены учетные записи, обязательная смена временного пароля, личный кабинет и управление пользователями для `admin`.
- Backend обновлён до `0.10.4`: многословный поиск получил низковесный OR fallback с требованием совпадения минимум двух терминов; fallback пропускает распознанные названия нормативных актов.
- Backend обновлён до `0.10.3`: запрос о налоге на прибыль поднимает действующий
Налоговый кодекс; точное совпадение фразы дополнительно усиливает результат.
- Backend обновлён до `0.10.2`: действующие документы ранжируются выше, а
утратившие силу — после остальных; из лаборатории удалён пустой блок фрагмента.
- Backend обновлён до `0.10.1`: поисковая выдача по регистрации больше не
ограничивается только тремя заранее выбранными нормативными актами.
- Backend обновлён до `0.10.0`: поисковый API использует общий планировщик
запросов, расширяет аббревиатуры ОсОО/ООО/ЖЧК и для запроса о регистрации
приоритизирует положение КР 2026 года, закон об обществах и ГК КР.
- Backend обновлён до `0.9.4`: из поисковых фрагментов убраны технические маркеры
подсветки OpenSearch.
- Backend обновлён до `0.9.3`: исправлен локальный индекс production Search API,
ошибки поиска показываются в общем status-блоке, а панель сохранения и footer
выстроены в обычном порядке документа.
- Backend обновлён до `0.9.2`: внутренняя лаборатория получила визуальный
polish-проход и короткий повторно запускаемый tutorial для новых проверяющих.
- Backend обновлён до `0.9.1`: все статусы из нормализованного корпуса доступны
как отдельные фильтры, варианты конституционного закона и указа сопоставляются
с подтверждённой юридической силой, неизвестные типы документов сохраняются
под общим фильтром без вывода юридической силы.
- Уточнено, что внутренняя лаборатория оценивает выдачу собственного OpenSearch;
ЦБД Минюста служит источником для подтверждения документов, а прежние Excel/PDF
бланки удалены.
- Исключены секреты и локальные данные из Docker build context; синхронизирована версия интерфейса.
- Добавлен production deployment baseline для Search API и OpenSearch с
постоянными хранилищами и healthcheck.
- Добавлено восстановление незавершённой разметки из `localStorage` после перезагрузки страницы.
- Добавлена внутренняя лаборатория проверки поисковой выдачи: просмотр документов,
оценка релевантности 0–3, комментарии и SQLite-экспорт подписанных снимков.
- Уточнены доступные состояния и адаптивное поведение внутреннего интерфейса
оценки поисковой выдачи.
- Добавлен план внутреннего интерфейса оценки поисковой выдачи юристами.
- Завершён Search API v1: стабильные справочники, валидный OpenAPI, безопасная
пагинация и проверка актуальных редакций в локальном OpenSearch.
- Добавлен фильтр Search API v1 по укрупнённой юридической силе. Полная
иерархия GeneralClassifiers экспортирована в отдельный черновой каталог;
тематическая навигация остаётся вне API v1 до ручного утверждения рубрик и
подписей.
- Добавлено безопасное переключение alias на новую версию поискового индекса
после полной загрузки; checkpoint защищает возобновление загрузки от смены
alias.
+325
View File
@@ -0,0 +1,325 @@
---
name: "Акылдаш · Түндүк"
description: "Утверждённая дизайн-система правового портала Кыргызской Республики"
colors:
bg: "#F7F2E8"
surface: "#FFFFFF"
surface-muted: "#FBF8F4"
text: "#38262A"
text-secondary: "#786469"
border: "#DED3CE"
primary: "#9D2633"
primary-hover: "#84202B"
primary-active: "#701B24"
on-primary: "#FFFFFF"
primary-subtle: "#F8EBED"
accent: "#A37B32"
highlight: "#FFF0C7"
focus: "#245C78"
success: "#246843"
success-bg: "#EAF4EC"
warning: "#82520D"
warning-bg: "#FFF3DA"
error: "#9D2633"
error-bg: "#FBEDEF"
info: "#245C78"
info-bg: "#EAF2F6"
typography:
document-title:
fontFamily: "Noto Serif, serif"
fontSize: "32px"
fontWeight: 700
lineHeight: 1.2
section-title:
fontFamily: "Noto Serif, serif"
fontSize: "24px"
fontWeight: 600
lineHeight: 1.3
document-body:
fontFamily: "Noto Sans, sans-serif"
fontSize: "18px"
fontWeight: 400
lineHeight: 1.65
control:
fontFamily: "Noto Sans, sans-serif"
fontSize: "16px"
fontWeight: 500
lineHeight: 1.5
metadata:
fontFamily: "Noto Sans, sans-serif"
fontSize: "14px"
fontWeight: 400
lineHeight: 1.5
rounded:
default: "6px"
spacing:
xs: "4px"
sm: "8px"
compact: "12px"
md: "16px"
lg: "24px"
xl: "32px"
section: "48px"
---
# Design System: Акылдаш · Түндүк
## Overview
**Статус: утверждено пользователем 10 октября 2026 года.** Это нормативная
дизайн-система портала и внутренней лаборатории проверки поисковой выдачи.
Часть ресурсов и правил уже присутствует в исходном коде, но полное визуальное
соответствие и доступность не подтверждаются одним наличием кода. Утверждение
дизайна не означает готовность публичного сервиса или разрешение на deployment.
**Creative North Star: "Түндүк — спокойное пространство для работы с правом".**
Профессиональный правовой сервис с кыргызской идентичностью: светлый фон
цвета войлока, белое поле документа, глубокий красный для действий,
тёмный сливовый текст и небольшие золотистые геометрические детали.
Главное — читаемость длинных документов и ясная иерархия.
Это единственная нормативная спецификация дизайна проекта. Требования
исходного brief перенесены сюда; отдельный brief и ранние варианты удалены.
Название продукта — **Акылдаш**; «УКУК» из ранних концепций не используется.
Целевые редактируемые состояния, композиция, измерения и критерии приёмки
собраны в [плане реализации и приёмки](docs/product/implementation-roadmap.md).
Макеты задают визуальное направление, но не источник юридических данных и не
доказательство работающих функций. Утверждённые токены не заменяются цветами,
взятыми пипеткой из растрового изображения. Изменения постоянных правил
вносятся сюда явно.
**Key Characteristics:**
- Спокойное чтение и компактные, понятные элементы управления.
- Национальная идентичность через палитру, знак и несколько точных деталей.
- Поиск и документ сохраняют общий контекст на широком экране.
- Единая визуальная система публичного сервиса и внутренней лаборатории.
- Содержимое и доступные функции определяются реальными данными и API.
## Colors
Нормативные значения находятся в YAML выше. При реализации ключи `colors`
переносятся в CSS custom properties `--color-<ключ>` либо эквивалентные
семантические токены существующей системы тем. HEX не размножаются
по компонентам.
| Роль | Токен | Применение |
|---|---|---|
| Светлый войлок | `bg` | Общий фон и окружение рабочей области |
| Белый лист | `surface` | Документ, поля и основные поверхности |
| Мягкая поверхность | `surface-muted` | Вторичные панели |
| Сливовые чернила | `text` | Основной текст и заголовки |
| Вторичный текст | `text-secondary` | Метаданные и пояснения |
| Разделители | `border` | Контуры и границы панелей |
| Глубокий красный | `primary` | Главное действие, ссылки и активная навигация |
| Состояния красного | `primary-hover`, `primary-active` | Hover и нажатие |
| Текст на красном | `on-primary` | Главная кнопка и активный пункт меню |
| Мягкое красное выделение | `primary-subtle` | Выбранный результат, вторичная навигация |
| Золотистая геометрия | `accent` | Небольшие декоративные линии |
| Найденный фрагмент | `highlight` | Подсветка совпадения с исходным тёмным текстом |
| Фокус | `focus` | Контрастная обводка клавиатурного фокуса |
| Успех | `success`, `success-bg` | Подтверждение действия, подтверждённый статус |
| Предупреждение | `warning`, `warning-bg` | Важные ограничения с текстовым объяснением |
| Ошибка | `error`, `error-bg` | Ошибка с доступным действием для восстановления |
| Информация | `info`, `info-bg` | Информационные сообщения |
Статус и ошибка всегда имеют текстовую подпись: цвет не передаёт смысл
самостоятельно. Не назначать юридический статус по визуальной эвристике;
использовать данные backend. Золотистый не используется для мелкого текста.
Контраст проверяется на фактических сочетаниях: обычный текст не менее
4.5:1, крупный текст и необходимые графические элементы не менее 3:1.
## Typography
**Заголовки документа и разделов:** Noto Serif, веса 600 и 700.
**Интерфейс и основной текст:** Noto Sans, веса 400, 500 и 600.
Шрифты подключаются штатным способом, предпочтительно локальными WOFF2
с `font-display: swap`. В исходном коде на сверенном `main` есть локальные
Noto Sans/Serif WOFF2 и OFL-лицензии в `backend/search/static/fonts/`;
`review.html` объявляет `font-display: swap`. Это подтверждает наличие ресурсов
и CSS, но не их загрузку на production и не полный визуальный аудит символов
`Ө ө Ү ү Ң ң`. См. [план реализации и приёмки](docs/product/implementation-roadmap.md).
| Элемент | Размер | Межстрочный интервал |
|---|---|---|
| Заголовок документа / страницы документа | 30–32 px; на телефоне 24–26 px | 1.2 |
| Заголовок раздела | 22–24 px | 1.3 |
| Текст документа | 17–18 px | 1.6–1.7 |
| Элементы управления | 14–16 px | По токену `control` |
| Метаданные | 13–14 px | По токену `metadata` |
Мелкий текст не ниже 12 px. Ориентир ширины документа — 65–75 символов
в строке. Длинные названия документов и кыргызские подписи переносятся,
не обрезаются. Даты, суммы и числовые колонки используют `tabular-nums`,
обычные данные не оформляются моноширинным шрифтом.
## Layout
Шкала отступов определяется `spacing`. Шапка на desktop — ориентировочно
72–88 px. Навигация / фильтры — 220–240 px. Панель контекста, когда
существуют соответствующие данные и функции, — 300–340 px. Центральная
колонка занимает остаток; это адаптивные ориентиры, а не фиксированная
ширина всей страницы.
Для поискового рабочего экрана: шапка с брендом, поиском и языком;
фильтры слева, результаты в середине, открытый документ справа. Оглавление
можно сворачивать в подписанную кнопку, чтобы не сужать область чтения.
Разворачивание документа и поиск внутри него реализуются только вместе
с работающим поведением, а не как декоративные кнопки.
### Композиция поискового экрана
- Первая строка desktop: знак и название, разделитель и подпись продукта
слева; подписанное поле поиска с очисткой и кнопкой «Найти» в центре;
переключатель языка интерфейса справа. Служебные действия авторизованного
пользователя не вытесняют поиск и не меняют эту иерархию.
- Вторая строка: навигация «Поиск» / «Об источниках» и удаляемые активные
фильтры. Сведения о демонстрационном режиме показываются только в нём.
- Ниже расположены три смежные панели: фильтры, результаты, документ.
Иерархия создаётся фонами и непрерывными тонкими разделителями, без
промежутков между самостоятельными карточками панелей.
- Фильтры имеют компактные группы, текстовое действие сброса, выбор органа
и пару полей даты в одной строке при достаточной ширине. Вид элемента
выбора соответствует одиночному или множественному контракту API.
- Заголовок результатов и счётчик находятся слева, сортировка справа.
Высота строк зависит от содержимого; едины отступы и типографика.
Выбранный результат имеет мягкий красный фон, тонкую красную рамку
и текстовый признак открытия.
- Панель документа начинается с контекстной навигации и разворачивания,
затем идут название, компактные метаданные, действия, поиск внутри
документа и единое поле чтения. Название документа имеет отдельную
типографическую роль, отличную от заголовков интерфейсных панелей.
- При коротком содержимом footer расположен у нижней границы окна;
длинное содержимое доступно прокруткой без перекрытия текста и управления.
Бренд находится слева, фактические версии frontend и backend справа.
- Поисковая страница не показывает инструменты оценивания лаборатории.
Общие стили и ресурсы не смешивают композицию этих двух сценариев.
Внутренняя лаборатория сохраняет свой двухпанельный сценарий: фактический
порядок результатов и оценивание слева, текст выбранной редакции справа.
Она не получает фильтры, аналитику или публичную навигацию исключительно
для сходства с концепцией. Панель проверяющего и сохранения не перекрывает
результаты и текст документа.
При недостатке места сначала сворачивается дополнительная панель контекста,
затем навигация. На телефоне — одна колонка; связанные материалы и
оглавление доступны через подписанные кнопки. В лаборатории сохраняется
существующее мобильное открытие документа и возврат фокуса при закрытии.
Поиск, действия и метаданные переносятся. Основной текст не требует
горизонтальной прокрутки; широкие таблицы имеют отдельную обозначенную
область прокрутки.
Проверочные размеры при реализации: **1280×800, 1440, 1024 и 390 px**.
1280×800 — основной размер сравнения поискового экрана с референсом.
Это размеры проверки, а не заранее навязанные CSS breakpoints.
## Elevation & Depth
Основные поверхности плоские. Иерархия создаётся белым листом на тёплом
фоне, отступами, типографикой и тонкими разделителями. Мягкая тень нужна
главным образом выпадающим меню и временным панелям. Значения теней
уточняются при реализации; декоративное размытие и обилие теней запрещены.
## Shapes
Основное скругление — `rounded.default` (6 px), контуры — 1 px.
Небольшие статусные метки могут быть округлее. Кнопки не превращаются
в капсулы. Границы интерактивных полей обозначаются явным контуром,
а не только фоновым цветом или разделителем соседней панели.
Если утверждённый логотип уже существует, он сохраняется. При отсутствии
логотипа допускается небольшой оригинальный геометрический знак,
вдохновлённый круглым верхним элементом юрты — түндүком. Знак из
генеративного макета является направлением, а не готовым SVG-ассетом.
Государственная символика не копируется.
Тонкий симметричный орнамент по мотивам шырдака допустим на краях шапки
и в пустой нижней части навигации. Он выполняется простым SVG,
помечается `aria-hidden`, не находится под текстом и не мешает нажатию.
На телефоне орнамент сокращается либо убирается.
Поисковый desktop включает небольшой золотистый орнамент в свободной нижней
части панели фильтров. Он не перекрывает длинные списки и не резервирует
место ценой недоступности управления. Знак бренда и орнамент поставляются
как SVG-ресурсы. Favicon использует тот же знак, адаптированный для малых
размеров; проверяется читаемость 16×16 и 32×32. Иконки управления имеют
согласованные толщину линий, оптический размер и выравнивание.
## Components
Это требования к будущей реализации, а не перечень уже обновлённых компонентов.
### Кнопки и поля
- Главная кнопка: `primary` и `on-primary`, высота около 40 px.
- Вторичная кнопка: светлая поверхность и явный контур.
- На touch-экране область нажатия не менее 44×44 px.
- Поля белые, с постоянной подписью; placeholder не заменяет label.
- Видимый фокус: обводка `focus` около 2 px с отступом.
- Hover, active, focus-visible, disabled, loading, empty и error
реализуются последовательно, с понятными сообщениями.
- Переходы 120–180 мс, без анимации текста документа;
учитывается `prefers-reduced-motion`.
### Навигация, результаты и документ
- Активный пункт меню — красный фон и белая подпись; вторичная
навигация — `primary-subtle` и тёмно-красный текст.
- Активная вкладка обозначена подписью и нижней линией. Длинные подписи
переносятся либо прокручиваются без обрезки.
- Результаты — упорядоченные строки: название, тип, статус, дата при
наличии и выдержка. Каждое поле не оборачивается в отдельную карточку.
- Выбранный результат обозначается мягким красным выделением и текстом.
- Документ — единый белый лист: заголовок, реальные метаданные,
доступные действия, оглавление и текст. Абзацы не разбиваются на карточки.
- Подсветка совпадения сохраняет исходный текст; число совпадений
и переходы между ними показываются только при работающем поиске.
- Источник и выбранная редакция доступны рядом с документом.
Недоступный язык не подменяется выдуманным переводом.
- Язык интерфейса и язык документа — отдельные состояния:
смена интерфейса не должна самовольно менять текст документа.
- Footer сохраняет версии frontend и backend. Отсутствующий frontend
или макет явно обозначаются, версия не придумывается.
### Внутренняя оценка выдачи
Сохраняются шкала 0–3 с полными текстовыми подписями, комментарии,
черновик, идентификация проверяющего, сохранение подписанного снимка,
обучение и существующие ограничения доступа. Оценки не передаются
только цветом. Служебные данные не становятся публичными из-за редизайна.
### Доступность и состояния
Семантические заголовки, labels, ссылки и кнопки; иконки без текста
имеют доступное имя. Проверяются Tab, Enter, Escape, возврат фокуса
и отсутствие клавиатурных ловушек. Статусы доступны скринридеру.
Пустая выдача предлагает изменить запрос; ошибка предлагает повторное
действие. Ошибка сохранения не уничтожает черновик.
## Do's and Don'ts
### Do:
- Сохранять название «Акылдаш», реальные данные, маршруты, API и права доступа.
- Использовать общий слой семантических токенов, существующие компоненты
и CSS Modules / BEM согласно правилам соответствующей части проекта.
- Разделять официальный текст, сведения о происхождении и выводы системы.
- Проверять чтение длинных RU/KY-документов и одноязычные редакции.
- Использовать этот документ как нормативную спецификацию для новых экранов.
### Don't:
- Не переносить демонстрационные даты, судебные материалы, связи
и юридические формулировки из изображения в продукт.
- Не добавлять аналитику, сравнение редакций, регистрацию и другие функции
только ради сходства с концепцией.
- Не менять поисковое ранжирование или сохранение разметки ради оформления.
- Не использовать ковровые текстуры, фотографии гор, иллюстрации юрт,
крупные флаги, золотые градиенты и декоративное размытие.
- Не делать золотистыми мелкие подписи и не передавать смысл одним цветом.
- Не добавлять отдельную тёмную тему без задачи; существующую сохранять.
- Не считать генеративный скриншот доказательством точных HEX,
загрузки Noto или проверки доступности работающего интерфейса.
- Не считать утверждение дизайна разрешением на публикацию сервиса.
+25 -11
View File
@@ -9,16 +9,29 @@ Telegram-бот — только часть рабочего окружения
## Текущее состояние
Сейчас реализованы Telegram-бот-секретарь версии `0.2.2` и backend версии
`0.6.0`: подготовка поискового индекса и оценка Recall@K/MRR@K на вручную
размеченном наборе запросов.
В исходном коде находятся Telegram-бот-секретарь версии `0.2.2`, Search API
версии `0.13.4` (алгоритм поиска `search-8`) и frontend `0.3.4` с поисковым
экраном, внутренней лабораторией и согласованным отображением доступных
действий учетной записи.
Авторизация хранится в
SQLite; первый пользователь `admin` создаётся интерактивной CLI-командой.
Поиск расширяет аббревиатуры организационно-правовых форм, применяет узкий
юридический вариант для общего словосочетания «договор аренды» и поднимает
действующий Налоговый кодекс для запросов о налоге на прибыль. Ограничения
текущего индекса и подтверждённые тестовые ошибки описаны в
[отчёте о релевантности](docs/product/search-relevance-findings-2026-11-03.md).
Версии описывают код, а не установленный production-релиз; актуальное
подтверждённое состояние среды см. в
[`docs/operations/CURRENT_STATE.md`](docs/operations/CURRENT_STATE.md).
| Компонент | Версия | Состояние |
|---|---:|---|
| Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния |
| Backend | `0.6.0` | добавлено атомарное переключение alias поискового индекса |
| Frontend | — | ещё не создан |
| Сбор и обработка правовых данных | `0.6.0` | добавлены relevance set и baseline-метрики |
| Telegram-бот | `0.2.2` | исходный код; последняя заметка о Synology указывает `0.2.1`, актуальность не перепроверена |
| Search API | `0.13.4` | авторизация, серверная capability управления пользователями, поиск и статические ресурсы |
| Frontend | `0.3.4` | поиск по правовой базе, просмотр редакций, внутренняя лаборатория и контекстная навигация кабинета |
| Загрузчик архива Минюста (`backend/ingestion/minjust_cbd.py`) | `0.9.3` (`APP_VERSION`) | версия CLI и User-Agent в исходном коде |
| Нормализатор корпуса (`backend/normalization/minjust_cbd.py`) | CLI `0.10.0` (`APP_VERSION`); формат/алгоритм `1.1.0` (`NORMALIZER_VERSION`) | версия инструмента и версия нормализатора в manifest — разные значения |
| Оценка релевантности | формат набора `relevance-set-v1` | отдельная версия компонента не задана; CLI использует Search API `0.13.4` |
| RAG и база знаний | — | ещё не созданы |
## Структура репозитория
@@ -34,11 +47,12 @@ tools/
backend/
ingestion/ получение и обновление правовых источников
normalization/ воспроизводимая нормализация исходного архива
search/ Search API, авторизация и внутренний frontend
```
Каталоги для загрузки и обработки источников, RAG, backend и frontend будут
создаваться с первой реальной задачей в соответствующей области. Это позволит
выбрать структуру по фактическим требованиям, а не поддерживать пустой каркас.
Внутренний frontend отдаётся существующим Search API без отдельного
Node-сервиса. Отдельный публичный frontend остаётся за readiness gate в
`docs/product/frontend-design-readiness-plan.md`.
Начать знакомство с проектом: [документация](docs/README.md) и
[обзор продукта](docs/product/project-overview.md).
@@ -59,4 +73,4 @@ python3 -m unittest discover -s tools/telegram-bot -v
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.6.0 · Frontend — не создан
Акылдаш · Telegram-бот v0.2.2 · Backend v0.13.4 · Frontend v0.3.4
+115 -5
View File
@@ -1,11 +1,16 @@
# Backend Акылдаш
Версия: `0.6.0`
Версия загрузчика `minjust_cbd.py`: `0.9.3`
Первая backend-область проекта — загрузка правовых документов из официального
Open Data API ЦБД Минюста Кыргызской Республики. Код расположен в
`ingestion/minjust_cbd.py`: его можно запускать как CLI сейчас и импортировать
из будущего планировщика backend без запуска отдельного процесса.
Для передачи корпуса в production обязательна отдельная процедура
[CORPUS_HANDOFF.md](../docs/operations/CORPUS_HANDOFF.md); она проверяет
source/staging, inventory и checksum, решение по code 6, публикацию и
готовность индекса. Этот runbook — release gate; команды передачи здесь не
дублируются.
## Пробная загрузка
@@ -54,6 +59,49 @@ result = sync_archive(output_path)
Планировщик, очередь задач и PostgreSQL пока не добавлены: модуль не зависит от
выбора будущего backend-фреймворка.
## Текущий API Минюста и отдельный staging
Старый OpenData endpoint может возвращать `404`; для отдельной ограниченной
выгрузки официальный сайт предоставляет `/api/v1/GetDocument` и
`/api/v1/GetEdition`. Адаптер `ingestion.minjust_current` получает один
указанный документ и сохраняет его в отдельное поколение staging:
```bash
PYTHONPATH=backend python3 -m ingestion.minjust_current \
--output /tmp/minjust-current-code6 \
--document-code 6
```
Продолжайте прерванную выгрузку только с тем же каталогом и `--resume`. По
умолчанию лимит скачанного текста одного документа — 4 GiB, лимит запросов —
один в секунду; каталог не должен совпадать или пересекаться с
`data/minjust-cbd` и
`data/minjust-normalized`. Адаптер сохраняет исходные ответы и checkpoints,
публикует папку документа атомарно и не заменяет существующий код в staging.
Каталог staging должен быть отдельной директорией режима 0700, принадлежащей
текущему пользователю; его родитель должен быть существующим доверенным
каталогом. Символические ссылки в generation, checkpoints и каталогах
документов отклоняются. Legacy-корни вычисляются от корня репозитория, а не
от текущего рабочего каталога.
Для `/GetEdition?lang=kg` официальный ответ помещает кыргызский HTML в
`contentRu` при `contentKg=null`; архивный язык определяется запрошенным
`lang`, а не именем поля ответа.
Исторические редакции запрашиваются только с `exact=true`; без этого параметра
официальный endpoint возвращал ID последней редакции даже для другого
запрошенного ID. Адаптер требует совпадения response ID и запрошенного ID,
сверяет названия RU/KY с metadata `GetDocument` и сохраняет request contract в
checkpoint. Старые staging generations без этого contract нельзя возобновить.
`exact=true` и совпадение ID проверены на двух контрольных редакциях; это не
подтверждает полноту всего источника.
Порядок редакций сохраняется по ответу `/GetDocument` и используется как
официальный порядок при нормализации и выборе текущей редакции в поисковом
индексе. Вложения и встроенные изображения этим адаптером не подтверждаются:
staging остаётся неполным до отдельной проверки медиа и не заменяет корпус.
Получение полного списка документов не является частью этой ограниченной
процедуры.
## Нормализация архива
Нормализатор читает исходный архив без изменений и создаёт отдельный набор
@@ -128,16 +176,31 @@ python3 backend/search/minjust_opensearch.py \
После проверки production-индекса следует переключать alias, чтобы удалённые
фрагменты не оставались в поиске.
Чтобы переключить alias атомарно только после успешной полной загрузки,
передайте `--alias`:
Alias переключается только после refresh, точного сравнения полного fragment
count с inventory, обеих языковых canary-проверок и совпадения ожидаемого
текущего индекса. Перед cutover обязательно укажите RU/KY запросы и точное имя
старого target:
```bash
python3 backend/search/minjust_opensearch.py \
--url http://127.0.0.1:9200 \
--index akyldash-fragments-v2 \
--alias akyldash-fragments-current
--alias akyldash-fragments-current \
--expected-old-index akyldash-fragments-v1 \
--canary-ru 'утверждённый русский запрос' \
--canary-ky 'бекитилген кыргызча суроо'
```
Wildcard removal, пустой индекс и alias с несколькими target запрещены. Если
старый target успел измениться после проверки, OpenSearch отклонит атомарную
операцию удаления точного старого alias. `--resume` повторно проверяет весь
ожидаемый inventory, а не только количество документов текущего запуска.
Если ответ на alias request потерян после успешного переключения или процесс
остановился до записи завершённого checkpoint, повторный запуск проверит
фактическую цель alias. Уже опубликованный candidate оставляется на месте, и
checkpoint завершается; старый target позволяет безопасно повторить cutover,
а любой другой target останавливает операцию.
После каждого принятого Bulk-пакета загрузчик атомарно сохраняет checkpoint и
печатает код безопасного возобновления. При временных HTTP 429/5xx, timeout и
обрыве соединения запрос повторяется автоматически. Прерванную загрузку можно
@@ -160,6 +223,21 @@ python3 backend/search/minjust_opensearch.py \
версионный индекс, проверить его и переключить alias. Это не оставляет
удалённые trailing-фрагменты старых документов.
## HTTP API v1
Запустите публичный read-only API поверх текущего alias и нормализованного
корпуса:
```bash
PYTHONPATH=backend python3 -m search.api
```
Он публикует OpenAPI в `GET /openapi.json` и поддерживает `GET /search`,
`/search/filters`, `/documents/{code}`, `/documents/{code}/editions` и
`/documents/{code}/editions/{edition}`. Значения фильтров возвращаются со
стабильным кодом справочника v1 и подписями RU/KY; применяйте `code` как
параметр поиска. API не подменяет отсутствующий язык документа.
## Локальный OpenSearch
Стенд использует один узел OpenSearch без Dashboards, устанавливает
@@ -201,6 +279,38 @@ PYTHONPATH=backend python3 -m search.evaluate_relevance \
Менять веса или анализаторы следует только после фиксации этого baseline и
разбора ошибок выдачи.
Диагностический отчёт с измерениями, подтверждёнными ошибками и ограничениями
текущего корпуса: [search relevance findings](../docs/product/search-relevance-findings-2026-11-03.md).
## Внутренняя лаборатория релевантности
Создайте первую учетную запись администратора:
```bash
PYTHONPATH=backend python3 -m search.api --users-db data/search-users.sqlite3 --create-admin
```
Команда запросит и подтвердит пароль `admin` в терминале. Затем запустите
Search API на localhost и откройте `http://127.0.0.1:8080/review`:
```bash
PYTHONPATH=backend python3 -m search.api \\
--reviews-db data/search-reviews.sqlite3
```
Лаборатория показывает фактический порядок выдачи OpenSearch, позволяет открыть
текст редакции, поставить результату оценку от 0 до 3 и сохранить снимок с
комментариями. Пустую выдачу можно сохранить как query-level оценку с
обязательным общим комментарием. Поиск и API доступны только после входа. Пользователь `admin`
управляет учетными записями; новые пользователи должны заменить временный
пароль при первом входе. Хранилище пользователей `data/search-users.sqlite3`
нужно резервировать отдельно от поискового индекса. Экспорт доступен
авторизованным пользователям через `GET /search-reviews/export`.
При утере пароля `admin` используйте `PYTHONPATH=backend python3 -m search.api
--users-db data/search-users.sqlite3 --reset-admin-password`; следующий вход
потребует сменить временный пароль.
Для проверки текущей выдачи без будущего HTTP API используйте CLI:
```bash
@@ -210,4 +320,4 @@ PYTHONPATH=backend python3 -m search.query "ЖЧК ачуу тартиби" --la
---
Акылдаш · Backend v0.6.0 · Frontend — не создан
Акылдаш · Search API v0.13.4 · Frontend v0.3.4 · Telegram-бот v0.2.2
+2 -2
View File
@@ -21,7 +21,7 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Callable, Iterable
APP_VERSION = "0.6.0"
APP_VERSION = "0.9.3"
API_BASE_URL = "https://cbd.minjust.gov.kg/api/v1/OpenData/"
LANGUAGES = {"Rus": "ru", "Kyr": "ky"}
IMAGE_LANGUAGES = {"Russian": "ru", "Kyrgyz": "ky"}
@@ -364,7 +364,7 @@ def main() -> int:
print(
f"discovered={result.discovered} downloaded={result.downloaded} "
f"skipped={result.skipped} failed={result.failed}\n"
f"Akyldash Backend v{APP_VERSION} · Frontend — not created"
f"Akyldash · Backend v{APP_VERSION} · Frontend v0.1.2"
)
return int(result.failed > 0)
+720
View File
@@ -0,0 +1,720 @@
"""Read-only staging client for the current MinJust CBD API."""
from __future__ import annotations
import argparse
import hashlib
import http.client
import json
import logging
import os
import sqlite3
import stat
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from pathlib import Path
from typing import Callable
from ingestion.minjust_cbd import atomic_write, connect_manifest, json_bytes
API_ROOT = "https://cbd.minjust.gov.kg/api/v1"
APP_VERSION = "0.13.0"
REQUEST_CONTRACT = {
"version": 2,
"endpoint": "/GetEdition",
"edition_id_parameter": "editionId",
"exact_parameter": "true",
"language_parameter": "lang",
"response_id_must_match": True,
}
MAX_RESPONSE_BYTES = 16 * 1024 * 1024
DEFAULT_DOCUMENT_BUDGET = 4 * 1024 * 1024 * 1024
LANGUAGES = (("ru", "ru", "textRusType"), ("kg", "ky", "textKyrType"))
LOGGER = logging.getLogger(__name__)
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
class SourceError(RuntimeError):
pass
class SourceChanged(SourceError):
pass
@dataclass(frozen=True)
class ApiResponse:
value: dict
body: bytes
sha256: str
class CurrentCbdClient:
def __init__(
self,
requests_per_second: float = 1,
timeout: int = 60,
retries: int = 5,
max_response_bytes: int = MAX_RESPONSE_BYTES,
opener: Callable = urllib.request.urlopen,
sleep: Callable[[float], None] = time.sleep,
clock: Callable[[], float] = time.monotonic,
) -> None:
if requests_per_second <= 0 or timeout <= 0 or retries <= 0 or max_response_bytes <= 0:
raise ValueError("rate, timeout, retries, and response limit must be positive")
self.interval = 1 / requests_per_second
self.timeout = timeout
self.retries = retries
self.max_response_bytes = max_response_bytes
self.opener = opener
self.sleep = sleep
self.clock = clock
self.last_request = 0.0
def request_json(
self,
path: str,
*,
method: str = "GET",
parameters: tuple[tuple[str, object], ...] = (),
payload: dict | None = None,
) -> ApiResponse:
if not path.startswith("/") or ".." in path.split("/"):
raise ValueError("API path must be an absolute endpoint path")
query = urllib.parse.urlencode(parameters, doseq=True)
url = API_ROOT + path + (f"?{query}" if query else "")
body = json.dumps(payload or {}, ensure_ascii=False, separators=(",", ":")).encode() if method == "POST" else None
for attempt in range(self.retries):
wait = self.interval - (self.clock() - self.last_request)
if wait > 0:
self.sleep(wait)
request = urllib.request.Request(
url,
data=body,
method=method,
headers={
"Accept": "application/json",
**({"Content-Type": "application/json"} if body is not None else {}),
"User-Agent": f"Akyldash/{APP_VERSION} (+https://cbd.minjust.gov.kg)",
},
)
try:
self.last_request = self.clock()
with self.opener(request, timeout=self.timeout) as response:
raw = response.read(self.max_response_bytes + 1)
if len(raw) > self.max_response_bytes:
raise SourceError(f"MinJust response exceeds {self.max_response_bytes} bytes")
value = json.loads(raw)
if not isinstance(value, dict):
raise SourceError("MinJust response must be a JSON object")
return ApiResponse(value, raw, hashlib.sha256(raw).hexdigest())
except urllib.error.HTTPError as error:
if error.code != 429 and error.code < 500:
raise SourceError(f"MinJust returned HTTP {error.code} for {path}") from error
delay = 2**attempt
if error.code == 429:
delay = max(delay, retry_after_seconds(error.headers.get("Retry-After")))
except (TimeoutError, urllib.error.URLError, http.client.IncompleteRead, json.JSONDecodeError) as error:
delay = 2**attempt
LOGGER.warning("MinJust request failed for %s (attempt %s/%s)", path, attempt + 1, self.retries)
last_error = error
if attempt + 1 < self.retries:
self.sleep(delay)
raise SourceError(f"MinJust request failed after {self.retries} attempts: {path}") from locals().get("last_error")
def document(self, alias: str) -> ApiResponse:
response = self.request_json("/GetDocument", parameters=(("DocumentCode", alias),))
value = response.value
canonical = value.get("documentCode")
editions = value.get("editions")
if not isinstance(canonical, int) or isinstance(canonical, bool) or canonical < 1:
raise SourceError("MinJust document has an invalid canonical documentCode")
if not isinstance(editions, list):
raise SourceError("MinJust document is missing editions")
ids = [edition_id(item) for item in editions]
if len(ids) != len(set(ids)):
raise SourceError(f"MinJust document {canonical} contains duplicate edition IDs")
return response
def edition(self, identifier: int, language: str) -> ApiResponse:
if identifier < 1 or language not in {"ru", "kg"}:
raise ValueError("edition ID or requested language is invalid")
response = self.request_json(
"/GetEdition",
parameters=(("editionId", identifier), ("lang", language), ("exact", "true")),
)
returned_id = response.value.get("id")
if not isinstance(returned_id, int) or isinstance(returned_id, bool) or returned_id < 1:
raise SourceError(f"MinJust returned an invalid edition response ID for {identifier}")
if returned_id != identifier:
raise SourceChanged(f"MinJust exact edition ID {returned_id} does not match requested ID {identifier}")
return response
def retry_after_seconds(value: str | None) -> float:
if not value:
return 0
try:
return max(0, float(value))
except ValueError:
try:
return max(0, (parsedate_to_datetime(value) - datetime.now(timezone.utc)).total_seconds())
except (TypeError, ValueError, OverflowError):
return 0
def edition_id(value: object) -> int:
if not isinstance(value, dict):
raise SourceError("MinJust document contains an invalid edition")
identifier = value.get("id")
if not isinstance(identifier, int) or isinstance(identifier, bool) or identifier < 1:
raise SourceError("MinJust edition has an invalid id")
return identifier
def open_manifest(path: Path) -> sqlite3.Connection:
connection = sqlite3.connect(path)
connection.execute("PRAGMA journal_mode=DELETE")
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS documents (
alias TEXT PRIMARY KEY,
canonical_id INTEGER,
response_sha256 TEXT,
expected_editions INTEGER,
state TEXT NOT NULL,
error TEXT,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS editions (
canonical_id INTEGER NOT NULL,
edition_id INTEGER NOT NULL,
source_order INTEGER NOT NULL,
response_sha256 TEXT NOT NULL,
PRIMARY KEY (canonical_id, edition_id)
);
CREATE TABLE IF NOT EXISTS languages (
canonical_id INTEGER NOT NULL,
edition_id INTEGER NOT NULL,
request_language TEXT NOT NULL,
archive_language TEXT NOT NULL,
response_sha256 TEXT,
content_sha256 TEXT,
byte_count INTEGER NOT NULL DEFAULT 0,
state TEXT NOT NULL,
error TEXT,
updated_at TEXT NOT NULL,
PRIMARY KEY (canonical_id, edition_id, request_language)
);
CREATE TABLE IF NOT EXISTS generation (
contract_sha256 TEXT NOT NULL
);
"""
)
return connection
def reject_symlink_components(path: Path) -> None:
current = Path(path.anchor)
for part in path.parts[1:]:
current /= part
try:
mode = current.lstat().st_mode
except FileNotFoundError:
continue
if stat.S_ISLNK(mode):
raise ValueError(f"staging path must not contain a symbolic link: {current}")
if current != path and not stat.S_ISDIR(mode):
raise ValueError(f"staging parent must be a directory: {current}")
def ensure_stage_path(root: Path, path: Path) -> Path:
root = root.absolute()
path = path.absolute()
try:
relative = path.relative_to(root)
except ValueError as error:
raise ValueError("staging file escaped its private generation") from error
current = root
if current.is_symlink():
raise ValueError("staging root must not be a symbolic link")
for part in relative.parts:
current /= part
try:
mode = current.lstat().st_mode
except FileNotFoundError:
continue
if stat.S_ISLNK(mode):
raise ValueError(f"staging path must not contain a symbolic link: {relative}")
if current != path and not stat.S_ISDIR(mode):
raise ValueError(f"staging parent must be a directory: {relative}")
return path
def stage_write(root: Path, path: Path, content: bytes) -> None:
path = ensure_stage_path(root, path)
atomic_write(path, content)
ensure_stage_path(root, path)
def stage_mkdir(root: Path, path: Path) -> None:
path = ensure_stage_path(root, path)
path.mkdir(parents=True, exist_ok=True)
ensure_stage_path(root, path)
if not path.is_dir():
raise ValueError("staging path must be a directory")
def validate_stage_tree(output: Path) -> None:
for relative in (
Path("current-api.sqlite3"), Path("current-api.sqlite3-journal"),
Path("current-api.sqlite3-wal"), Path("current-api.sqlite3-shm"),
Path("manifest.sqlite3"), Path("manifest.sqlite3-journal"),
Path("manifest.sqlite3-wal"), Path("manifest.sqlite3-shm"),
Path("request-contract.json"),
):
path = ensure_stage_path(output, output / relative)
if path.exists() and not path.is_file():
raise ValueError(f"staging checkpoint must be a regular file: {relative}")
for relative in (Path(".work"), Path("documents")):
root = ensure_stage_path(output, output / relative)
if root.exists() and not root.is_dir():
raise ValueError(f"staging path must be a directory: {relative}")
if root.is_dir():
for directory, directories, files in os.walk(root, followlinks=False):
for name in directories + files:
ensure_stage_path(output, Path(directory) / name)
def validate_stage_root(output: Path, resume: bool) -> Path:
requested = Path(output)
if ".." in requested.parts:
raise ValueError("staging output path must not contain '..'")
destination = Path(os.path.abspath(requested))
reject_symlink_components(destination)
parent = destination.parent
if not parent.is_dir():
raise ValueError("staging output parent must already exist")
for ancestor in (parent, *parent.parents):
info = ancestor.lstat()
mode = stat.S_IMODE(info.st_mode)
if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode):
raise ValueError("staging output ancestors must be real directories")
sticky_parent = bool(mode & stat.S_ISVTX)
if mode & 0o022 and not sticky_parent:
raise ValueError("staging output parent is not trusted")
for relative in (Path("data/minjust-cbd"), Path("data/minjust-normalized")):
legacy = (REPOSITORY_ROOT / relative).resolve()
if destination == legacy or destination.is_relative_to(legacy) or legacy.is_relative_to(destination):
raise ValueError("current API staging must not overlap the existing corpus")
if destination.exists():
if destination.is_symlink() or not destination.is_dir():
raise ValueError("staging output must be a real directory")
else:
destination.mkdir(mode=0o700)
root_info = destination.lstat()
root_mode = stat.S_IMODE(root_info.st_mode)
if (
stat.S_ISLNK(root_info.st_mode)
or not stat.S_ISDIR(root_info.st_mode)
or root_info.st_uid != os.geteuid()
or root_mode & 0o077
or (root_mode & 0o700) != 0o700
):
raise ValueError("staging output must be a private directory owned by the current user")
validate_stage_tree(destination)
if any(destination.iterdir()) and not resume:
raise ValueError("staging output is not empty; use --resume for this generation")
contract_path = destination / "request-contract.json"
if contract_path.exists():
try:
contract = json.loads(contract_path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as error:
raise SourceChanged("staging generation request contract is invalid") from error
if contract != REQUEST_CONTRACT:
raise SourceChanged("staging generation uses a different request contract")
else:
if any(destination.iterdir()):
raise SourceChanged("staging generation has no exact-edition request contract; start a new generation")
stage_write(destination, contract_path, json_bytes(REQUEST_CONTRACT))
return destination
def stage_document(
output: Path,
alias: str,
client: CurrentCbdClient,
*,
resume: bool = False,
max_document_bytes: int = DEFAULT_DOCUMENT_BUDGET,
) -> dict:
"""Stage one document and its official editions without touching legacy roots."""
if not alias or len(alias) > 64 or "/" in alias or "\\" in alias:
raise ValueError("document code is invalid")
if max_document_bytes <= 0:
raise ValueError("document byte budget must be positive")
output = validate_stage_root(output, resume)
work_root = output / ".work" / "documents"
target_root = output / "documents"
connection = open_manifest(output / "current-api.sqlite3")
raw_manifest = connect_manifest(output / "manifest.sqlite3")
try:
contract_sha = hashlib.sha256(json.dumps(REQUEST_CONTRACT, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
saved_contract = connection.execute("SELECT contract_sha256 FROM generation").fetchone()
if saved_contract and saved_contract[0] != contract_sha:
raise SourceChanged("checkpoint request contract changed; start a new staging generation")
if not saved_contract:
if connection.execute("SELECT COUNT(*) FROM documents").fetchone()[0]:
raise SourceChanged("existing checkpoints have no request contract; start a new staging generation")
with connection:
connection.execute("INSERT INTO generation (contract_sha256) VALUES (?)", (contract_sha,))
response = client.document(alias)
source = response.value
canonical = source["documentCode"]
record_sha = response.sha256
editions = source["editions"]
previous = connection.execute(
"SELECT canonical_id, response_sha256, expected_editions FROM documents WHERE alias = ?",
(alias,),
).fetchone()
if previous and previous[1] is not None and (
previous[0] != canonical or previous[1] != record_sha or previous[2] != len(editions)
):
raise SourceChanged("document metadata changed; start a new staging generation")
working_document = work_root / str(canonical)
final_document = target_root / str(canonical)
ensure_stage_path(output, working_document)
ensure_stage_path(output, final_document)
if final_document.is_symlink():
raise ValueError("staging target must not be a symbolic link")
if final_document.exists() and not previous:
raise ValueError("staging target already exists without a matching checkpoint")
if final_document.exists():
final_metadata = ensure_stage_path(output, final_document / "metadata.json")
source_document = ensure_stage_path(output, final_document / "source-document.json")
metadata = json.loads(final_metadata.read_text(encoding="utf-8"))
if (
metadata.get("SourceApi", {}).get("response_sha256") != record_sha
or hashlib.sha256(source_document.read_bytes()).hexdigest() != record_sha
):
raise SourceChanged("published staging document does not match the current source record")
for edition in editions:
identifier = edition_id(edition)
for request_language, archive_language, type_field in LANGUAGES:
html_path = ensure_stage_path(
output,
final_document / "editions" / str(identifier) / f"{archive_language}.html",
)
checkpoint = connection.execute(
"SELECT state, content_sha256 FROM languages WHERE canonical_id = ? AND edition_id = ? AND request_language = ?",
(canonical, identifier, request_language),
).fetchone()
if not edition.get(type_field):
if not checkpoint or checkpoint[0] != "unavailable":
raise SourceError(f"published staging document has no checkpoint for edition {identifier} {request_language}")
elif (
not checkpoint
or checkpoint[0] != "complete"
or not html_path.is_file()
or sha256_file(html_path) != checkpoint[1]
):
raise SourceError(f"published staging document failed verification for edition {identifier} {request_language}")
document_sha = hash_tree(final_document)
fetched_at = utc_now()
manifest_row = raw_manifest.execute(
"SELECT source_sha256 FROM documents WHERE code = ?", (canonical,)
).fetchone()
if manifest_row and manifest_row[0] != document_sha:
raise SourceError("published staging document does not match its raw manifest")
with raw_manifest:
raw_manifest.execute(
"INSERT OR IGNORE INTO documents (code, source_sha256, fetched_at) VALUES (?, ?, ?)",
(canonical, document_sha, fetched_at),
)
raw_manifest.execute(
"INSERT INTO errors (code, message, failed_at) VALUES (?, ?, ?) "
"ON CONFLICT(code) DO UPDATE SET message=excluded.message, failed_at=excluded.failed_at",
(canonical, "attachments and embedded media are not independently verified", fetched_at),
)
with connection:
connection.execute(
"UPDATE documents SET state='text_complete_incomplete_attachments', error=NULL, updated_at=? WHERE alias=?",
(fetched_at, alias),
)
return {
"alias": alias,
"canonical_id": canonical,
"editions": len(editions),
"source_sha256": document_sha,
"state": "text_complete_incomplete_attachments",
"resumed": True,
}
stage_mkdir(output, working_document)
document_metadata = map_document_metadata(source, alias, response.sha256)
stage_write(output, working_document / "metadata.json", json_bytes(document_metadata))
stage_write(output, working_document / "source-document.json", response.body)
with connection:
connection.execute(
"INSERT INTO documents (alias, canonical_id, response_sha256, expected_editions, state, updated_at) VALUES (?, ?, ?, ?, 'fetching', ?) "
"ON CONFLICT(alias) DO UPDATE SET canonical_id=excluded.canonical_id, response_sha256=excluded.response_sha256, "
"expected_editions=excluded.expected_editions, state='fetching', error=NULL, updated_at=excluded.updated_at",
(alias, canonical, record_sha, len(editions), utc_now()),
)
for order, edition in enumerate(editions):
identifier = edition_id(edition)
existing = connection.execute(
"SELECT source_order, response_sha256 FROM editions WHERE canonical_id = ? AND edition_id = ?",
(canonical, identifier),
).fetchone()
if existing and existing != (order, hashlib.sha256(json_bytes(edition)).hexdigest()):
raise SourceChanged(f"edition metadata changed for {identifier}")
connection.execute(
"INSERT OR IGNORE INTO editions (canonical_id, edition_id, source_order, response_sha256) VALUES (?, ?, ?, ?)",
(canonical, identifier, order, hashlib.sha256(json_bytes(edition)).hexdigest()),
)
incomplete = []
for order, edition in enumerate(editions):
identifier = edition_id(edition)
directory = working_document / "editions" / str(identifier)
stage_mkdir(output, directory)
stage_write(output, directory / "metadata.json", json_bytes(map_edition_metadata(edition, order)))
for request_language, archive_language, type_field in LANGUAGES:
html_path = ensure_stage_path(output, directory / f"{archive_language}.html")
checkpoint = connection.execute(
"SELECT state, content_sha256, byte_count FROM languages WHERE canonical_id = ? AND edition_id = ? AND request_language = ?",
(canonical, identifier, request_language),
).fetchone()
if checkpoint and checkpoint[0] == "complete":
if not html_path.is_file() or sha256_file(html_path) != checkpoint[1]:
raise SourceError(f"staged content hash mismatch for edition {identifier} {request_language}")
continue
if not edition.get(type_field):
save_language_checkpoint(
connection, canonical, identifier, request_language, archive_language,
None, None, 0, "unavailable", "source metadata reports no language file",
)
continue
try:
language_response = client.edition(identifier, request_language)
response_id = language_response.value.get("id")
if isinstance(response_id, bool) or not isinstance(response_id, int) or response_id != identifier:
raise SourceChanged(f"MinJust exact edition ID mismatch for {identifier}")
for field in ("nameRus", "nameKyr"):
if edition.get(field) is not None and language_response.value.get(field) != edition[field]:
raise SourceChanged(f"MinJust exact edition metadata mismatch for {identifier}: {field}")
content = language_response.value.get("contentRu")
if not isinstance(content, str) or not content:
raise SourceError(f"MinJust returned no content for edition {identifier} language {request_language}")
encoded = content.encode("utf-8")
completed_bytes = connection.execute(
"SELECT COALESCE(SUM(byte_count), 0) FROM languages WHERE canonical_id = ? AND state = 'complete'",
(canonical,),
).fetchone()[0]
if completed_bytes + len(encoded) > max_document_bytes:
raise SourceError(f"document {canonical} exceeds its {max_document_bytes}-byte staging budget")
response_metadata = {
key: value
for key, value in language_response.value.items()
if key not in {"contentRu", "contentKg"}
}
response_metadata["requested_language"] = request_language
response_metadata["archive_language"] = archive_language
response_metadata["requested_edition_id"] = identifier
response_metadata["exact"] = True
response_metadata["response_sha256"] = language_response.sha256
stage_write(
output,
directory / "source" / f"{request_language}.json",
json_bytes(response_metadata),
)
stage_write(output, html_path, encoded)
save_language_checkpoint(
connection, canonical, identifier, request_language, archive_language,
language_response.sha256, hashlib.sha256(encoded).hexdigest(), len(encoded), "complete", None,
)
except Exception as error:
message = str(error)[:500]
save_language_checkpoint(
connection, canonical, identifier, request_language, archive_language,
None, None, 0, "error", message,
)
incomplete.append({"edition_id": identifier, "language": request_language, "error": message})
if isinstance(error, SourceChanged) or (isinstance(error, SourceError) and "budget" in message):
raise
if incomplete:
with connection:
connection.execute(
"UPDATE documents SET state='incomplete', error=?, updated_at=? WHERE alias=?",
(json.dumps(incomplete, ensure_ascii=False), utc_now(), alias),
)
return {"alias": alias, "canonical_id": canonical, "editions": len(editions), "incomplete": incomplete}
working_metadata = ensure_stage_path(output, working_document / "metadata.json")
metadata = json.loads(working_metadata.read_text(encoding="utf-8"))
metadata["SourceCompleteness"] = {
"text": "complete",
"attachments": "not independently verified",
}
stage_write(output, working_metadata, json_bytes(metadata))
stage_mkdir(output, target_root)
ensure_stage_path(output, working_document)
ensure_stage_path(output, final_document)
if final_document.exists():
raise ValueError("refusing to replace a document in this staging generation")
os.replace(working_document, final_document)
validate_stage_tree(output)
document_sha = hash_tree(final_document)
fetched_at = utc_now()
with raw_manifest:
raw_manifest.execute(
"INSERT INTO documents (code, source_sha256, fetched_at) VALUES (?, ?, ?)",
(canonical, document_sha, fetched_at),
)
raw_manifest.execute(
"INSERT INTO errors (code, message, failed_at) VALUES (?, ?, ?)",
(canonical, "attachments and embedded media are not independently verified", fetched_at),
)
with connection:
connection.execute(
"UPDATE documents SET state='text_complete_incomplete_attachments', updated_at=? WHERE alias=?",
(fetched_at, alias),
)
return {
"alias": alias,
"canonical_id": canonical,
"editions": len(editions),
"source_sha256": document_sha,
"state": "text_complete_incomplete_attachments",
}
except Exception as error:
with connection:
connection.execute(
"INSERT INTO documents (alias, state, error, updated_at) VALUES (?, 'error', ?, ?) "
"ON CONFLICT(alias) DO UPDATE SET state='error', error=excluded.error, updated_at=excluded.updated_at",
(alias, str(error)[:500], utc_now()),
)
raise
finally:
raw_manifest.close()
connection.close()
def map_document_metadata(source: dict, alias: str, response_sha256: str) -> dict:
canonical = source["documentCode"]
return {
"Code": canonical,
"Name": {"Rus": source.get("nameRus"), "Kyr": source.get("nameKyr")},
"Title": {"Rus": source.get("headerRu"), "Kyr": source.get("headerKg")},
"Number": source.get("number"),
"DateAdopted": source.get("dateAdopted"),
"DateInclusion": source.get("dateInclusion"),
"DateOfEntry": source.get("dateOfEntry"),
"DatePublication": source.get("datePublication"),
"DateRegistration": source.get("dateRegistration"),
"IsPublicInRegister": source.get("isPublicInRegister"),
"SourceStatus": source.get("status"),
"SourceClassId": source.get("classId"),
"SourceRefTypeId": source.get("refTypeId"),
"SourceAuthorityIds": source.get("authoritiesId"),
"SourcePublicationIds": source.get("sourcePublicationsId"),
"SourceMetadata": {key: value for key, value in source.items() if key != "editions"},
"SourceDocumentCode": alias,
"SourceMediaStatus": "not independently verified",
"SourceApi": {"endpoint": "/GetDocument", "response_sha256": response_sha256},
}
def map_edition_metadata(source: dict, source_order: int) -> dict:
return {
"Code": edition_id(source),
"Name": {"Rus": source.get("nameRus"), "Kyr": source.get("nameKyr")},
"SourceOrder": source_order,
"SourceTextTypes": {
"ru": source.get("textRusType"),
"ky": source.get("textKyrType"),
},
"SourceMediaStatus": "not independently verified",
"SourceMetadata": source,
}
def save_language_checkpoint(
connection: sqlite3.Connection,
canonical: int,
edition: int,
request_language: str,
archive_language: str,
response_sha256: str | None,
content_sha256: str | None,
byte_count: int,
state: str,
error: str | None,
) -> None:
with connection:
connection.execute(
"INSERT INTO languages (canonical_id, edition_id, request_language, archive_language, response_sha256, content_sha256, byte_count, state, error, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(canonical_id, edition_id, request_language) DO UPDATE SET "
"archive_language=excluded.archive_language, response_sha256=excluded.response_sha256, content_sha256=excluded.content_sha256, "
"byte_count=excluded.byte_count, state=excluded.state, error=excluded.error, updated_at=excluded.updated_at",
(canonical, edition, request_language, archive_language, response_sha256, content_sha256, byte_count, state, error, utc_now()),
)
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as source:
for block in iter(lambda: source.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def hash_tree(root: Path) -> str:
digest = hashlib.sha256()
for path in sorted(item for item in root.rglob("*") if item.is_file()):
digest.update(path.relative_to(root).as_posix().encode())
digest.update(b"\0")
digest.update(sha256_file(path).encode("ascii"))
digest.update(b"\0")
return digest.hexdigest()
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, required=True, help="new, separate staging generation")
parser.add_argument("--document-code", required=True, help="one official document alias or canonical code")
parser.add_argument("--resume", action="store_true", help="resume only this staging generation")
parser.add_argument("--requests-per-second", type=float, default=1)
parser.add_argument("--timeout", type=int, default=60)
parser.add_argument("--retries", type=int, default=5)
parser.add_argument("--max-document-bytes", type=int, default=DEFAULT_DOCUMENT_BUDGET)
parser.add_argument("--version", action="version", version=APP_VERSION)
return parser.parse_args()
def main() -> int:
arguments = parse_args()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
result = stage_document(
arguments.output,
arguments.document_code,
CurrentCbdClient(arguments.requests_per_second, arguments.timeout, arguments.retries),
resume=arguments.resume,
max_document_bytes=arguments.max_document_bytes,
)
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
return int(result.get("state") != "text_complete_incomplete_attachments")
if __name__ == "__main__":
raise SystemExit(main())
+16 -4
View File
@@ -23,9 +23,9 @@ from pathlib import Path
from typing import Callable
from urllib.parse import urlsplit
APP_VERSION = "0.6.0"
APP_VERSION = "0.10.0"
SCHEMA_VERSION = "1"
NORMALIZER_VERSION = "1.0.0"
NORMALIZER_VERSION = "1.1.0"
LANGUAGES = ("ru", "ky")
LOGGER = logging.getLogger(__name__)
@@ -376,13 +376,16 @@ def load_json(path: Path) -> dict:
def edition_summary(edition_directory: Path, input_root: Path) -> dict:
metadata = load_json(edition_directory / "metadata.json")
languages = [language for language in LANGUAGES if (edition_directory / f"{language}.html").is_file()]
return {
summary = {
"source_code": str(metadata.get("Code", edition_directory.name)),
"name": bilingual(metadata.get("Name")),
"source_type": clean_value(metadata.get("Type")),
"available_languages": languages,
"source_path": edition_directory.relative_to(input_root).as_posix(),
}
if "SourceOrder" in metadata:
summary["source_order"] = metadata["SourceOrder"]
return summary
def normalize_document(
@@ -406,6 +409,15 @@ def normalize_document(
key=lambda path: (not path.name.isdigit(), int(path.name) if path.name.isdigit() else path.name),
) if edition_root.is_dir() else []
summaries = [edition_summary(path, input_root) for path in edition_directories]
source_orders = [item.get("source_order") for item in summaries]
if any(order is not None for order in source_orders):
if any(not isinstance(order, int) or isinstance(order, bool) or order < 0 for order in source_orders):
raise ValueError(f"Invalid source edition order in {document_directory}")
if len(set(source_orders)) != len(source_orders):
raise ValueError(f"Duplicate source edition order in {document_directory}")
ordered = sorted(zip(source_orders, edition_directories, summaries), key=lambda item: item[0])
edition_directories = [item[1] for item in ordered]
summaries = [item[2] for item in ordered]
available_languages = [language for language in LANGUAGES if any(language in item["available_languages"] for item in summaries)]
normalized_metadata = clean_value(metadata)
document = {
@@ -720,7 +732,7 @@ def main() -> int:
print(
f"discovered={result.discovered} normalized={result.normalized} "
f"skipped={result.skipped} failed={result.failed}\n"
f"Akyldash Backend v{APP_VERSION} · Frontend — not created"
f"Akyldash · Backend v{APP_VERSION} · Frontend v0.1.2"
)
return int(result.failed > 0)
+13 -11
View File
@@ -56,17 +56,19 @@ cp backend/search/relevance-set-v1.template.json data/search/relevance-set-v1.js
## Разметка одного запроса
1. До просмотра выдачи зафиксируйте информационную потребность и формулировку
`query`.
2. Найдите кандидатов в локальном OpenSearch и в официальной ЦБД Минюста.
Проверьте исходный запрос, его короткий вариант и вариант с юридическим
термином или известным номером акта.
3. Просмотрите не только заголовки, но и текст, статус, дату и редакцию каждого
кандидата.
4. Запишите уникальные `document_code` всех документов, удовлетворяющих
критерию релевантности.
5. Повторите поиск по ключевым терминам найденных документов, чтобы обнаружить
пропущенные альтернативные акты.
1. Зафиксируйте исходную формулировку `query` и информационную потребность до
оценки выдачи нашего поиска.
2. Юрист устанавливает релевантные акты по содержанию и реквизитам документов.
Используйте ЦБД Минюста как авторитетный источник для проверки текста,
статуса и редакции акта. Порядок выдачи ЦБД не является объектом оценки и не
переносится в эталон.
3. Запишите уникальные `document_code` всех документов, которые прямо отвечают
на запрос. Спорные документы передайте на независимую проверку.
4. Зафиксируйте согласованный набор до просмотра результатов OpenSearch и
сохраните его отдельно от рабочих данных.
5. Проверьте запрос во внутренней лаборатории (`/review`): оцените фактические
результаты OpenSearch в исходном порядке, сохраните снимок и комментарии.
Лаборатория проверяет качество нашей поисковой системы, а не ЦБД Минюста.
Пример структуры (код условный):
+549
View File
@@ -0,0 +1,549 @@
"""Minimal HTTP API for the normalized legal-document corpus."""
from __future__ import annotations
import argparse
import datetime
import getpass
import http.cookies
import json
import os
import re
import urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from search.auth import AuthError, AuthStore, can_manage_users
from search.minjust_opensearch import APP_VERSION, request_json
from search.catalog import CATALOGS, labels
from search.query import build_search_query
from search.reviews import ReviewSnapshots, ReviewStore
API_VERSION = "v1"
SEARCH_ALGORITHM_VERSION = "search-8"
LANGUAGES = {"ru", "ky"}
CODE = re.compile(r"^[0-9]+$")
MAX_PAGE_SIZE = 100
MAX_RESULT_WINDOW = 10_000
STATIC_ASSETS = {
"/static/favicon.svg": ("favicon.svg", "image/svg+xml"),
"/static/favicon-32.png": ("favicon-32.png", "image/png"),
"/static/fonts/noto-sans-cyrillic-ext.woff2": ("fonts/noto-sans-cyrillic-ext.woff2", "font/woff2"),
"/static/fonts/noto-sans-cyrillic.woff2": ("fonts/noto-sans-cyrillic.woff2", "font/woff2"),
"/static/fonts/noto-sans-latin.woff2": ("fonts/noto-sans-latin.woff2", "font/woff2"),
"/static/fonts/noto-serif-cyrillic-ext.woff2": ("fonts/noto-serif-cyrillic-ext.woff2", "font/woff2"),
"/static/fonts/noto-serif-cyrillic.woff2": ("fonts/noto-serif-cyrillic.woff2", "font/woff2"),
"/static/fonts/noto-serif-latin.woff2": ("fonts/noto-serif-latin.woff2", "font/woff2"),
}
class ApiError(Exception):
def __init__(self, status: int, message: str):
self.status = status
self.message = message
def parse_positive(value: str | None, name: str, default: int, maximum: int) -> int:
if value is None:
return default
try:
parsed = int(value)
except ValueError as error:
raise ApiError(400, f"{name} must be an integer") from error
if not 1 <= parsed <= maximum:
raise ApiError(400, f"{name} must be between 1 and {maximum}")
return parsed
def one(query: dict[str, list[str]], name: str) -> str | None:
values = query.get(name, [])
if len(values) > 1:
raise ApiError(400, f"{name} must be specified once")
return values[0] if values else None
def date(value: str | None, name: str) -> str | None:
if value is None:
return None
try:
datetime.date.fromisoformat(value)
except ValueError as error:
raise ApiError(400, f"{name} must be an ISO date") from error
return value
def openapi() -> dict:
responses = {"200": {"description": "Successful response"}, "400": {"description": "Invalid request"}, "401": {"description": "Authentication required"}, "403": {"description": "Additional access required"}, "404": {"description": "Not found"}, "502": {"description": "Search backend unavailable"}}
return {
"openapi": "3.0.3",
"info": {"title": "Akyldash Search API", "version": API_VERSION},
"components": {"securitySchemes": {"SessionCookie": {"type": "apiKey", "in": "cookie", "name": "akyldash_session"}}},
"security": [{"SessionCookie": []}],
"paths": {
"/auth/login": {"post": {"security": [], "responses": {"200": {"description": "Signed in and session cookie issued"}, "401": {"description": "Invalid login or password"}}}},
"/auth/logout": {"post": {"responses": {"200": {"description": "Signed out"}}}},
"/auth/me": {"get": {"responses": {"200": {"description": "Current account"}, "401": {"description": "Authentication required"}}}},
"/auth/change-password": {"post": {"description": "Requires the current password and a different new password", "responses": {"200": {"description": "Password changed"}, "400": {"description": "Current password is missing or invalid, or the new password is invalid or unchanged"}}}},
"/admin/users": {"get": {"responses": {"200": {"description": "User list"}}}, "post": {"responses": {"201": {"description": "User created with a temporary password"}}}},
"/admin/users/{username}": {"delete": {"responses": {"200": {"description": "User deleted"}}}},
"/admin/users/{username}/reset-password": {"post": {"responses": {"200": {"description": "Temporary password set"}}}},
"/search": {"get": {"responses": responses, "parameters": [
{"name": "q", "in": "query", "required": True, "schema": {"type": "string"}},
{"name": "language", "in": "query", "schema": {"type": "string", "enum": ["ru", "ky"]}},
{"name": "page", "in": "query", "schema": {"type": "integer", "minimum": 1}},
{"name": "page_size", "in": "query", "schema": {"type": "integer", "minimum": 1, "maximum": MAX_PAGE_SIZE}},
{"name": "document_type", "in": "query", "schema": {"type": "string"}},
{"name": "status", "in": "query", "schema": {"type": "string"}},
{"name": "authority", "in": "query", "schema": {"type": "string"}},
{"name": "legal_force", "in": "query", "schema": {"type": "string"}},
{"name": "date_from", "in": "query", "schema": {"type": "string", "format": "date"}},
{"name": "date_to", "in": "query", "schema": {"type": "string", "format": "date"}},
{"name": "sort", "in": "query", "schema": {"type": "string", "enum": ["relevance", "date"]}},
]}},
"/search/filters": {"get": {"responses": responses}},
"/search-reviews": {"post": {"responses": {"201": {"description": "Review saved"}, "400": {"description": "Invalid review"}}}},
"/search-reviews/export": {"get": {"responses": responses}},
"/review": {"get": {"responses": {"200": {"description": "Review interface"}}}},
"/documents/{code}": {"get": {"responses": responses, "parameters": [{"name": "code", "in": "path", "required": True, "schema": {"type": "string", "pattern": "^[0-9]+$"}}]}},
"/documents/{code}/editions": {"get": {"responses": responses, "parameters": [{"name": "code", "in": "path", "required": True, "schema": {"type": "string", "pattern": "^[0-9]+$"}}]}},
"/documents/{code}/editions/{edition}": {"get": {"responses": responses, "parameters": [{"name": "code", "in": "path", "required": True, "schema": {"type": "string", "pattern": "^[0-9]+$"}}, {"name": "edition", "in": "path", "required": True, "schema": {"type": "string", "pattern": "^[0-9]+$"}}]}},
},
}
class Api:
def __init__(self, base_url: str, index: str, data_root: Path, reviews_db: Path | str = ":memory:", review_secret: bytes | None = None, users_db: Path | str = ":memory:", cookie_secure: bool = False):
self.base_url = base_url.rstrip("/")
self.index = index
self.data_root = data_root
self.review_store = ReviewStore(reviews_db)
self.review_snapshots = ReviewSnapshots(review_secret)
self.auth_store = AuthStore(users_db)
self.cookie_secure = cookie_secure
def search_url(self, suffix: str) -> str:
return f"{self.base_url}/{urllib.parse.quote(self.index, safe='')}/{suffix}"
def query_opensearch(self, body: dict, *, search_type: str | None = None) -> dict:
try:
url = self.search_url("_search")
if search_type is not None:
url = f"{url}?{urllib.parse.urlencode({'search_type': search_type})}"
return request_json(url, "POST", json.dumps(body, ensure_ascii=False).encode(), "application/json")
except RuntimeError as error:
raise ApiError(502, "search backend is unavailable") from error
def search(self, query: dict[str, list[str]]) -> dict:
text = one(query, "q")
if not text or not text.strip():
raise ApiError(400, "q is required")
if len(text) > 500:
raise ApiError(400, "q must not exceed 500 characters")
language = one(query, "language") or "ru"
if language not in LANGUAGES:
raise ApiError(400, "language must be ru or ky")
page = parse_positive(one(query, "page"), "page", 1, 1_000_000)
page_size = parse_positive(one(query, "page_size"), "page_size", 20, MAX_PAGE_SIZE)
if page * page_size >= MAX_RESULT_WINDOW:
raise ApiError(400, f"page and page_size must stay within {MAX_RESULT_WINDOW} results")
sort = one(query, "sort") or "relevance"
if sort not in {"relevance", "date"}:
raise ApiError(400, "sort must be relevance or date")
filters: list[dict] = [{"term": {"language": language}}, {"term": {"is_current_edition": True}}]
fields = {"document_type": "document_type_code", "status": "status_code", "authority": "authority_codes", "legal_force": "legal_force_code"}
for parameter, field in fields.items():
value = one(query, parameter)
if value:
if value not in CATALOGS[parameter]:
raise ApiError(400, f"{parameter} must be a catalog code")
filters.append({"term": {field: value}})
date_from, date_to = date(one(query, "date_from"), "date_from"), date(one(query, "date_to"), "date_to")
if date_from and date_to and date_from > date_to:
raise ApiError(400, "date_from must not be later than date_to")
if date_from or date_to:
date_range = {key: value for key, value in (("gte", date_from), ("lte", date_to)) if value}
filters.append({"range": {"date_adopted": date_range}})
body = {
"from": (page - 1) * page_size,
"size": page_size + 1,
"_source": ["document_code", "edition_code", "document_name_ru", "document_name_ky", "document_type_ru", "document_type_ky", "status_ru", "status_ky", "legal_force_code", "date_adopted", "number"],
"query": {"bool": {"filter": filters, "must": build_search_query(language, text)}},
"collapse": {"field": "document_code"},
"highlight": {"fields": {f"text_{language}": {"number_of_fragments": 1}}},
}
body["sort"] = [{"_script": {
"type": "number",
"order": "asc",
"script": {"lang": "painless", "source": "doc['status_code'].size() == 0 ? 1 : (doc['status_code'].value == 'active' ? 0 : (doc['status_code'].value == 'repealed' ? 2 : 1))"},
}}]
if sort == "date":
body["sort"].append({"date_adopted": "desc"})
body["sort"].append({"_score": "desc"})
search_type = "dfs_query_then_fetch" if "," in self.index else None
response = self.query_opensearch(body, search_type=search_type)
try:
hits = response["hits"]["hits"]
except (KeyError, TypeError) as error:
raise ApiError(502, "search backend returned an incomplete response") from error
results = [self.search_hit(hit, language) for hit in hits[:page_size]]
snapshot_results = [{"rank": rank, **result} for rank, result in enumerate(results, 1)]
concrete_indexes = {hit.get("_index") for hit in hits[:page_size] if hit.get("_index")}
# A comma-separated index expression is the searched dataset scope;
# hits may come from only one of its members (or there may be no hits).
# For a single configured index/alias, keep the concrete-index provenance
# when OpenSearch returns exactly one concrete target.
if "," in self.index:
index_name = self.index
else:
index_name = next(iter(concrete_indexes)) if len(concrete_indexes) == 1 else self.index
return {"api_version": API_VERSION, "query": text, "language": language, "page": page, "page_size": page_size, "has_next": len(hits) > page_size, "results": results, "review_token": self.review_snapshots.create(text, language, index_name, snapshot_results, SEARCH_ALGORITHM_VERSION)}
@staticmethod
def search_hit(hit: dict, language: str) -> dict:
source = hit.get("_source")
if not isinstance(source, dict) or not source.get("document_code"):
raise ApiError(502, "search backend returned an incomplete result")
highlight = hit.get("highlight", {}).get(f"text_{language}", [])
legal_force = source.get("legal_force_code")
snippet = highlight[0].replace("<em>", "").replace("</em>", "") if highlight else None
return {"code": source["document_code"], "edition": source.get("edition_code"), "name": source.get(f"document_name_{language}"), "type": source.get(f"document_type_{language}"), "status": source.get(f"status_{language}"), "legal_force": labels("legal_force", legal_force)[language] if legal_force else None, "date_adopted": source.get("date_adopted"), "number": source.get("number"), "snippet": snippet}
def filters(self, query: dict[str, list[str]]) -> dict:
language = one(query, "language") or "ru"
if language not in LANGUAGES:
raise ApiError(400, "language must be ru or ky")
fields = {"document_types": ("document_type", "document_type_code"), "statuses": ("status", "status_code"), "authorities": ("authority", "authority_codes"), "legal_forces": ("legal_force", "legal_force_code")}
body = {"size": 0, "query": {"term": {"is_current_edition": True}}, "aggs": {name: {"terms": {"field": pair[1], "size": 1000}, "aggs": {"documents": {"cardinality": {"field": "document_code", "precision_threshold": 40000}}}} for name, pair in fields.items()}}
response = self.query_opensearch(body)
try:
aggregations = response["aggregations"]
values = {
name: [{"code": item["key"], "labels": labels(pair[0], item["key"]), "count": item["documents"]["value"]} for item in aggregations[name]["buckets"]]
for name, pair in fields.items()
}
except (KeyError, TypeError) as error:
raise ApiError(502, "search backend returned incomplete filters") from error
return {"api_version": API_VERSION, "language": language, **values}
def directory(self, code: str) -> Path:
if not CODE.fullmatch(code):
raise ApiError(404, "document not found")
path = self.data_root / "documents" / code
if not path.is_dir():
raise ApiError(404, "document not found")
return path
@staticmethod
def read_json(path: Path, message: str) -> dict:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as error:
raise ApiError(500, message) from error
if not isinstance(value, dict):
raise ApiError(500, message)
return value
def document(self, code: str) -> dict:
document = self.read_json(self.directory(code) / "document.json", "document data is unavailable")
editions = document.get("editions")
if not isinstance(editions, list):
raise ApiError(500, "document data is unavailable")
return {"api_version": API_VERSION, "document": document, "current_edition": editions[-1] if editions else None}
def editions(self, code: str) -> dict:
document = self.read_json(self.directory(code) / "document.json", "document data is unavailable")
return {"api_version": API_VERSION, "code": code, "available_languages": document.get("available_languages", []), "editions": document.get("editions", [])}
def edition(self, code: str, edition: str, query: dict[str, list[str]]) -> dict:
if not CODE.fullmatch(edition):
raise ApiError(404, "edition not found")
directory = self.directory(code) / "editions" / edition
if not directory.is_dir():
raise ApiError(404, "edition not found")
metadata = self.read_json(directory / "edition.json", "edition data is unavailable")
language = one(query, "language")
if language is not None and language not in LANGUAGES:
raise ApiError(400, "language must be ru or ky")
languages = [language] if language else metadata.get("available_languages", [])
content = {}
for item in languages:
if item not in metadata.get("available_languages", []):
continue
try:
content[item] = {"html": (directory / item / "content.html").read_text(encoding="utf-8"), "text": (directory / item / "content.txt").read_text(encoding="utf-8")}
except (OSError, UnicodeError) as error:
raise ApiError(500, "edition content is unavailable") from error
if language and language not in content:
raise ApiError(404, "edition language not found")
return {"api_version": API_VERSION, "edition": metadata, "content": content}
def save_review(self, body: dict, username: str) -> dict:
if not isinstance(body, dict):
raise ApiError(400, "request body must be an object")
try:
snapshot = self.review_snapshots.verify(body["review_token"])
reviewer = username
overall_comment = body.get("overall_comment", "").strip()
submitted = body["results"]
except (KeyError, AttributeError, TypeError, ValueError) as error:
raise ApiError(400, "review_token, reviewer and results are required") from error
if not reviewer or len(reviewer) > 120:
raise ApiError(400, "reviewer must be between 1 and 120 characters")
if len(overall_comment) > 4000:
raise ApiError(400, "overall_comment is too long")
if not isinstance(submitted, list):
raise ApiError(400, "results must be an array")
if len(submitted) > MAX_PAGE_SIZE:
raise ApiError(400, f"results must not exceed {MAX_PAGE_SIZE} items")
if any(not isinstance(item, dict) for item in submitted):
raise ApiError(400, "review result must be an object")
ranks = [item.get("rank") for item in submitted]
if any(type(rank) is not int for rank in ranks):
raise ApiError(400, "review result rank is invalid")
by_rank = {item["rank"]: item for item in snapshot["results"]}
if len(ranks) != len(set(ranks)) or len(submitted) != len(by_rank) or set(ranks) != set(by_rank):
raise ApiError(400, "all search results must be reviewed exactly once")
if not snapshot["results"] and not overall_comment.strip():
raise ApiError(400, "overall_comment is required for an empty search result")
results = []
for item in submitted:
if not isinstance(item, dict) or not isinstance(item.get("rank"), int) or item["rank"] not in by_rank:
raise ApiError(400, "review result rank is invalid")
source = by_rank[item["rank"]]
if item.get("code") != source["code"]:
raise ApiError(400, "review result document does not match the search snapshot")
rating = item.get("rating")
if rating is not None and (isinstance(rating, bool) or not isinstance(rating, int) or not 0 <= rating <= 3):
raise ApiError(400, "rating must be an integer from 0 to 3")
comment = item.get("comment", "")
if not isinstance(comment, str) or len(comment) > 4000:
raise ApiError(400, "result comment is too long")
results.append({**source, "rating": rating, "comment": comment.strip()})
review = {"created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(), "reviewer": reviewer, "query": snapshot["query"], "language": snapshot["language"], "index_name": snapshot["index_name"], "algorithm_version": snapshot["algorithm_version"], "top_result_code": snapshot["results"][0]["code"] if snapshot["results"] else None, "results": results, "overall_comment": overall_comment}
review_id = self.review_store.save(review)
return {"api_version": API_VERSION, "id": review_id, "created_at": review["created_at"]}
@staticmethod
def review_page() -> str:
try:
return Path(__file__).with_name("review.html").read_text(encoding="utf-8")
except (OSError, UnicodeError) as error:
raise ApiError(500, "review interface is unavailable") from error
@staticmethod
def static_asset(route: str) -> tuple[str, bytes]:
asset = STATIC_ASSETS.get(route)
if asset is None:
raise ApiError(404, "static asset not found")
path = Path(__file__).with_name("static") / asset[0]
try:
return asset[1], path.read_bytes()
except OSError as error:
raise ApiError(500, "static asset is unavailable") from error
def handle(self, method: str, path: str, body: dict | None = None, user: dict | None = None) -> tuple[int, dict]:
parsed = urllib.parse.urlsplit(path)
query = urllib.parse.parse_qs(parsed.query, keep_blank_values=True)
parts = [urllib.parse.unquote(part) for part in parsed.path.split("/") if part]
if method == "POST" and parts == ["auth", "login"]:
try:
token, account = self.auth_store.login(body.get("username"), body.get("password"))
return 200, {"account": account, "session_token": token}
except AuthError as error:
raise ApiError(401, str(error)) from error
if method == "POST" and parts == ["auth", "logout"]:
return 200, {"ok": True}
if method == "GET" and parts == ["auth", "me"]:
return 200, {"account": user}
if method == "POST" and parts == ["auth", "change-password"]:
try:
self.auth_store.change_password(user["username"], body.get("current_password"), body.get("new_password"))
token, account = self.auth_store.login(user["username"], body.get("new_password"))
return 200, {"account": account, "session_token": token}
except AuthError as error:
raise ApiError(400, str(error)) from error
if user and can_manage_users(user["username"]) and method == "GET" and parts == ["admin", "users"]:
return 200, {"users": self.auth_store.list_users()}
if user and can_manage_users(user["username"]) and method == "POST" and parts == ["admin", "users"]:
try:
self.auth_store.create_user(body.get("username"), body.get("password"))
return 201, {"ok": True}
except AuthError as error:
raise ApiError(400, str(error)) from error
if user and can_manage_users(user["username"]) and method == "DELETE" and len(parts) == 3 and parts[:2] == ["admin", "users"]:
try:
self.auth_store.delete_user(parts[2])
return 200, {"ok": True}
except AuthError as error:
raise ApiError(400, str(error)) from error
if user and can_manage_users(user["username"]) and method == "POST" and len(parts) == 4 and parts[:2] == ["admin", "users"] and parts[3] == "reset-password":
try:
self.auth_store.reset_password(parts[2], body.get("password"))
return 200, {"ok": True}
except AuthError as error:
raise ApiError(400, str(error)) from error
if method == "POST" and parts == ["search-reviews"]:
return 201, self.save_review(body, user["username"] if user else body.get("reviewer", ""))
if method == "GET" and parts == ["search-reviews", "export"]:
return 200, {"api_version": API_VERSION, "reviews": self.review_store.export()}
if method != "GET":
raise ApiError(405, "method not allowed")
if parts == ["openapi.json"]:
return 200, openapi()
if parts == ["search"]:
return 200, self.search(query)
if parts == ["search", "filters"]:
return 200, self.filters(query)
if len(parts) == 2 and parts[0] == "documents":
return 200, self.document(parts[1])
if len(parts) == 3 and parts[:1] == ["documents"] and parts[2] == "editions":
return 200, self.editions(parts[1])
if len(parts) == 4 and parts[:1] == ["documents"] and parts[2] == "editions":
return 200, self.edition(parts[1], parts[3], query)
raise ApiError(404, "endpoint not found")
def handler(api: Api):
class RequestHandler(BaseHTTPRequestHandler):
def respond(self, method: str):
try:
route = urllib.parse.urlsplit(self.path).path
cookies = http.cookies.SimpleCookie(self.headers.get("Cookie", ""))
session = cookies.get("akyldash_session")
token = session.value if session else None
user = api.auth_store.user_for_token(token)
if method == "GET" and route in {"/", "/review", "/users"}:
body = api.review_page().encode()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("X-Frame-Options", "DENY")
self.send_header("Referrer-Policy", "no-referrer")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
if method == "GET" and route in STATIC_ASSETS:
content_type, body = api.static_asset(route)
self.send_response(200)
self.send_header("Content-Type", content_type)
self.send_header("Cache-Control", "public, max-age=604800, immutable")
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("X-Frame-Options", "DENY")
self.send_header("Referrer-Policy", "no-referrer")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
if not (method == "POST" and route in {"/auth/login", "/auth/logout"}):
if not user:
raise ApiError(401, "authentication required")
if user["must_change_password"] and not (method == "POST" and route == "/auth/change-password") and not (method == "GET" and route == "/auth/me"):
raise ApiError(403, "change password before continuing")
if route.startswith("/admin/users") and not can_manage_users(user["username"]):
raise ApiError(403, "admin account required")
body = None
if method in {"POST", "DELETE"}:
length = int(self.headers.get("Content-Length", "0"))
if length > 1_000_000:
raise ApiError(413, "request body is too large")
body = json.loads(self.rfile.read(length) or b"{}")
if not isinstance(body, dict):
raise ApiError(400, "request body must be a JSON object")
if method == "POST" and route == "/auth/logout":
api.auth_store.logout(token)
status, payload = api.handle(method, self.path, body, user)
except json.JSONDecodeError:
status, payload = 400, {"api_version": API_VERSION, "error": "request body must be valid JSON"}
except ApiError as error:
status, payload = error.status, {"api_version": API_VERSION, "error": error.message}
except (ValueError, http.cookies.CookieError):
status, payload = 400, {"api_version": API_VERSION, "error": "invalid request"}
session_token = payload.pop("session_token", None) if isinstance(payload, dict) else None
body = json.dumps(payload, ensure_ascii=False).encode()
self.send_response(status)
if session_token:
secure = "; Secure" if api.cookie_secure else ""
self.send_header("Set-Cookie", f"akyldash_session={session_token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=43200{secure}")
elif method == "POST" and urllib.parse.urlsplit(self.path).path == "/auth/logout":
secure = "; Secure" if api.cookie_secure else ""
self.send_header("Set-Cookie", f"akyldash_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0{secure}")
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("X-Frame-Options", "DENY")
self.send_header("Referrer-Policy", "no-referrer")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
self.respond("GET")
def do_POST(self):
self.respond("POST")
def do_DELETE(self):
self.respond("DELETE")
def log_message(self, format: str, *args):
return
return RequestHandler
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--url", default="http://127.0.0.1:9200")
parser.add_argument("--index", default="akyldash-fragments-current")
parser.add_argument("--data", type=Path, default=Path("data/minjust-normalized"))
parser.add_argument("--reviews-db", type=Path, default=Path("data/search-reviews.sqlite3"))
parser.add_argument("--users-db", type=Path, default=Path("data/search-users.sqlite3"))
parser.add_argument("--review-secret", default=None)
parser.add_argument("--review-secret-file", type=Path)
parser.add_argument("--cookie-secure", action=argparse.BooleanOptionalAction, default=os.environ.get("COOKIE_SECURE", "false").lower() == "true")
parser.add_argument("--create-admin", action="store_true", help="create the initial admin account and exit")
parser.add_argument("--reset-admin-password", action="store_true", help="set a temporary password for admin and exit")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8080)
parser.add_argument("--version", action="version", version=APP_VERSION)
arguments = parser.parse_args()
if arguments.review_secret and arguments.review_secret_file:
parser.error("choose either --review-secret or --review-secret-file")
try:
secret = arguments.review_secret.encode() if arguments.review_secret else arguments.review_secret_file.read_bytes().strip() if arguments.review_secret_file else None
except OSError as error:
parser.error(f"cannot read review secret file: {error}")
if arguments.review_secret_file and not secret:
parser.error("review secret file is empty")
api = Api(arguments.url, arguments.index, arguments.data, arguments.reviews_db, secret, arguments.users_db, arguments.cookie_secure)
if arguments.create_admin or arguments.reset_admin_password:
users = api.auth_store.list_users()
admin_exists = any(can_manage_users(user["username"]) for user in users)
if arguments.create_admin and users:
parser.error("accounts already exist")
if arguments.reset_admin_password and not admin_exists:
parser.error("admin account does not exist")
if arguments.create_admin and arguments.reset_admin_password:
parser.error("choose either --create-admin or --reset-admin-password")
password = getpass.getpass("Password for admin (12+ characters): ")
confirm = getpass.getpass("Confirm password: ")
if password != confirm:
parser.error("passwords do not match")
try:
if arguments.create_admin:
api.auth_store.create_admin(password)
else:
api.auth_store.reset_password("admin", password)
except AuthError as error:
parser.error(str(error))
print("Initial admin account created." if arguments.create_admin else "Admin password reset. Change it at next sign-in.")
return 0
ThreadingHTTPServer((arguments.host, arguments.port), handler(api)).serve_forever()
return 0
if __name__ == "__main__":
raise SystemExit(main())
+183
View File
@@ -0,0 +1,183 @@
"""Local user accounts and cookie sessions for the internal search workspace."""
from __future__ import annotations
import hashlib
import hmac
import os
import re
import secrets
import sqlite3
import threading
import time
from pathlib import Path
USERNAME = re.compile(r"^[A-Za-z0-9._-]{3,64}$")
PASSWORD_ITERATIONS = 310_000
SESSION_TTL = 60 * 60 * 12
class AuthError(ValueError):
pass
def can_manage_users(username: str) -> bool:
return isinstance(username, str) and username.casefold() == "admin"
class AuthStore:
def __init__(self, path: Path | str):
if path != ":memory:":
database = Path(path)
database.parent.mkdir(parents=True, exist_ok=True)
try:
descriptor = os.open(database, os.O_CREAT | os.O_EXCL | os.O_RDWR, 0o600)
os.close(descriptor)
except FileExistsError:
pass
self.connection = sqlite3.connect(path, check_same_thread=False)
self.connection.row_factory = sqlite3.Row
self._lock = threading.RLock()
self.connection.executescript("""
CREATE TABLE IF NOT EXISTS users (
username TEXT PRIMARY KEY COLLATE NOCASE,
password_hash BLOB NOT NULL,
password_salt BLOB NOT NULL,
must_change_password INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
first_login_at INTEGER,
last_login_at INTEGER,
password_reset_at INTEGER
);
CREATE TABLE IF NOT EXISTS sessions (
token_hash BLOB PRIMARY KEY,
username TEXT NOT NULL REFERENCES users(username) ON DELETE CASCADE,
expires_at INTEGER NOT NULL
);
""")
user_columns = {row["name"] for row in self.connection.execute("PRAGMA table_info(users)")}
for column in ("first_login_at", "last_login_at", "password_reset_at"):
if column not in user_columns:
self.connection.execute(f"ALTER TABLE users ADD COLUMN {column} INTEGER")
self.connection.execute("PRAGMA foreign_keys = ON")
self.connection.commit()
@staticmethod
def _validate(username: str, password: str) -> tuple[str, str]:
if not isinstance(username, str) or not USERNAME.fullmatch(username):
raise AuthError("login must be 3–64 letters, numbers, dots, underscores or hyphens")
if not isinstance(password, str) or not 12 <= len(password) <= 256:
raise AuthError("password must be between 12 and 256 characters")
return username, password
@staticmethod
def _hash(password: str, salt: bytes) -> bytes:
return hashlib.pbkdf2_hmac("sha256", password.encode(), salt, PASSWORD_ITERATIONS)
def create_admin(self, password: str) -> None:
self._validate("admin", password)
salt = secrets.token_bytes(16)
with self._lock:
if self.connection.execute("SELECT 1 FROM users LIMIT 1").fetchone():
raise AuthError("accounts already exist")
self.connection.execute("INSERT INTO users(username,password_hash,password_salt,must_change_password,created_at) VALUES('admin',?,?,0,?)", (self._hash(password, salt), salt, int(time.time())))
self.connection.commit()
# shortcut: login throttling belongs to the HTTPS reverse proxy; add local limits before direct public exposure.
def login(self, username: str, password: str) -> tuple[str, dict]:
if not isinstance(username, str) or not isinstance(password, str):
raise AuthError("invalid login or password")
with self._lock:
user = self.connection.execute("SELECT * FROM users WHERE username = ? COLLATE NOCASE", (username,)).fetchone()
salt = user["password_salt"] if user else b"\0" * 16
expected_hash = user["password_hash"] if user else b"\0" * 32
valid = hmac.compare_digest(self._hash(password, salt), expected_hash)
if not user or not valid:
raise AuthError("invalid login or password")
with self._lock:
current = self.connection.execute("SELECT * FROM users WHERE username=?", (user["username"],)).fetchone()
if not current or not hmac.compare_digest(current["password_hash"], expected_hash):
raise AuthError("invalid login or password")
token = secrets.token_urlsafe(32)
now = int(time.time())
self.connection.execute("DELETE FROM sessions WHERE expires_at <= ?", (now,))
self.connection.execute("INSERT INTO sessions(token_hash,username,expires_at) VALUES(?,?,?)", (hashlib.sha256(token.encode()).digest(), user["username"], now + SESSION_TTL))
self.connection.execute("UPDATE users SET first_login_at=COALESCE(first_login_at,?),last_login_at=? WHERE username=?", (now, now, user["username"]))
self.connection.commit()
current = self.connection.execute("SELECT * FROM users WHERE username=?", (user["username"],)).fetchone()
return token, self._public(current)
@staticmethod
def _public(user: sqlite3.Row) -> dict:
can_manage = can_manage_users(user["username"])
return {"username": user["username"], "role": "Администратор" if can_manage else "Пользователь", "can_manage_users": can_manage, "must_change_password": bool(user["must_change_password"]), "created_at": user["created_at"]}
def user_for_token(self, token: str | None) -> dict | None:
if not token:
return None
with self._lock:
user = self.connection.execute("SELECT users.* FROM sessions JOIN users USING(username) WHERE token_hash = ? AND expires_at > ?", (hashlib.sha256(token.encode()).digest(), int(time.time()))).fetchone()
return self._public(user) if user else None
def logout(self, token: str | None) -> None:
if token:
with self._lock:
self.connection.execute("DELETE FROM sessions WHERE token_hash = ?", (hashlib.sha256(token.encode()).digest(),))
self.connection.commit()
def change_password(self, username: str, old_password: str, new_password: str) -> None:
if not isinstance(old_password, str):
raise AuthError("current password is required")
self._validate(username, new_password)
with self._lock:
user = self.connection.execute("SELECT * FROM users WHERE username = ?", (username,)).fetchone()
if not user:
raise AuthError("account not found")
if not hmac.compare_digest(self._hash(old_password, user["password_salt"]), user["password_hash"]):
raise AuthError("current password is incorrect")
if hmac.compare_digest(self._hash(new_password, user["password_salt"]), user["password_hash"]):
raise AuthError("new password must be different")
salt = secrets.token_bytes(16)
password_hash = self._hash(new_password, salt)
self.connection.execute("UPDATE users SET password_hash=?,password_salt=?,must_change_password=0 WHERE username=?", (password_hash, salt, username))
self.connection.execute("DELETE FROM sessions WHERE username=?", (username,))
self.connection.commit()
def create_user(self, username: str, password: str) -> None:
self._validate(username, password)
salt = secrets.token_bytes(16)
password_hash = self._hash(password, salt)
try:
with self._lock:
self.connection.execute("INSERT INTO users(username,password_hash,password_salt,must_change_password,created_at) VALUES(?,?,?,1,?)", (username, password_hash, salt, int(time.time())))
self.connection.commit()
except sqlite3.IntegrityError as error:
raise AuthError("login already exists") from error
def delete_user(self, username: str) -> None:
if can_manage_users(username):
raise AuthError("admin account cannot be deleted")
with self._lock:
self.connection.execute("DELETE FROM users WHERE username=?", (username,))
self.connection.commit()
def reset_password(self, username: str, password: str) -> None:
self._validate(username, password)
salt = secrets.token_bytes(16)
password_hash = self._hash(password, salt)
with self._lock:
cursor = self.connection.execute("UPDATE users SET password_hash=?,password_salt=?,must_change_password=1,password_reset_at=? WHERE username=?", (password_hash, salt, int(time.time()), username))
if not cursor.rowcount:
raise AuthError("account not found")
self.connection.execute("DELETE FROM sessions WHERE username=?", (username,))
self.connection.commit()
def list_users(self) -> list[dict]:
with self._lock:
users = []
for row in self.connection.execute("SELECT * FROM users ORDER BY username COLLATE NOCASE"):
account = self._public(row)
account.update(first_login_at=row["first_login_at"], last_login_at=row["last_login_at"], password_reset_at=row["password_reset_at"])
users.append(account)
return users
+79
View File
@@ -0,0 +1,79 @@
"""Immutable v1 search catalogs."""
DOCUMENT_TYPES = {
"constitution": ("Конституция", "Конституция"), "constitutional_law": ("Конституционный Закон", "Конституциалык Мыйзам"), "code": ("Кодекс", "Кодекс"), "law": ("Закон", "Мыйзам"), "decree": ("Указ", "Жарлык"), "resolution": ("Постановление", "Токтом"), "order": ("Распоряжение", "Распоряжение"), "instruction": ("Инструкция", "Инструкция"), "rules": ("Правила", "Правила"), "procedure": ("Порядок", "Порядок"), "provision": ("Положение", "Жобо"), "regulation": ("Регламент", "Регламент"), "charter": ("Устав", "Жобо (Устав)"), "program": ("Программа", "Программа"), "plan": ("План", "План"), "strategy": ("Стратегия", "Стратегия"), "concept": ("Концепция", "Концепция"), "doctrine": ("Доктрина", "Доктрина"), "agreement": ("Соглашение", "Соглашение"), "declaration": ("Декларация", "Декларация"), "registry": ("Реестр", "Реестр"), "norms": ("Нормативы", "Нормативы"), "model": ("Модель", "Модель"), "matrix": ("Матрица", "Матрица"), "study": ("Исследование", "Исследование"), "report": ("Доклад", "Доклад"), "principles": ("Основные принципы", "Основные принципы"), "other": ("Прочие документы", "Башка документтер"), "unspecified": ("Не указан", "Көрсөтүлгөн эмес"),
}
STATUSES = {"active": ("Действует", "Күчүндө"), "repealed": ("Утратил силу", "Күчүн жоготту"), "not_yet_effective": ("Не вступил в силу", "Күчүнө кире элек"), "canceled": ("Отменено", "Жокко чыгарылды"), "inactive": ("Не действует", "Күчүндө эмес же Колдонулбайт"), "terminated": ("Прекратило действие", "Күчүн токтотту же Колдонуу токтотулган"), "suspended": ("Действие приостановлено", "Күчүнө кирүүсү токтотулган"), "unspecified": ("Не указан", "Көрсөтүлгөн эмес")}
AUTHORITIES = {"president": ("Президент", "Президент"), "parliament": ("Органы законодательной власти", "Мыйзам чыгаруу бийлик органдары"), "cabinet": ("Правительство и Кабинет Министров", "Өкмөт жана Министрлер Кабинети"), "ministries_and_committees": ("Министерства и государственные комитеты", "Министрликтер жана мамлекеттик комитеттер"), "administrative_agencies": ("Административные ведомства", "Административдик ведомстволор"), "national_bank": ("Национальный банк", "Улуттук банк"), "other_state_bodies": ("Иные государственные органы", "Башка мамлекеттик органдар"), "local_representative_bodies": ("Представительные органы местного самоуправления", "Жергиликтүү өз алдынча башкаруунун өкүлчүлүктүү органдары"), "other": ("Прочие органы", "Башка органдар")}
LEGAL_FORCE_LEVELS = {
"constitutional": ("Конституционный уровень", "Конституциялык деңгээл"),
"legislative": ("Законодательный уровень", "Мыйзам деңгээли"),
"subordinate": ("Подзаконный уровень", "Мыйзам алдындагы деңгээл"),
}
LEGAL_FORCE_BY_DOCUMENT_TYPE = {
"constitution": "constitutional",
"constitutional_law": "constitutional",
"code": "legislative",
"law": "legislative",
"decree": "subordinate",
"resolution": "subordinate",
}
CATALOGS = {"document_type": DOCUMENT_TYPES, "status": STATUSES, "authority": AUTHORITIES, "legal_force": LEGAL_FORCE_LEVELS}
DOCUMENT_TYPE_ALIASES = {
("Положение", "Положение"): "provision",
("Конституционный закон", "Конституционный закон"): "constitutional_law",
("Указ", "Жарлыгы"): "decree",
("устав", None): "charter",
}
def labels(category: str, code: str) -> dict[str, str]:
try:
ru, ky = CATALOGS[category][code]
except KeyError as error:
raise ValueError(f"Unknown {category} catalog code: {code}") from error
return {"ru": ru, "ky": ky}
def source_code(category: str, value: dict | None) -> str:
pair = ((value or {}).get("ru"), (value or {}).get("ky"))
if pair == (None, None):
return "unspecified"
if category == "document_type" and pair in DOCUMENT_TYPE_ALIASES:
return DOCUMENT_TYPE_ALIASES[pair]
for code, expected in CATALOGS[category].items():
if pair == expected:
return code
# ponytail: uncurated source types share one filter; add reviewed codes when separate filtering is needed.
if category == "document_type":
return "other"
raise ValueError(f"Unmapped {category} catalog value: {pair!r}")
def authority_codes(paths: list[dict]) -> list[str]:
codes = set()
for path in paths:
text = " ".join(path.get("ru", []) + path.get("ky", [])).lower()
if "президент" in text:
codes.add("president")
elif "жогорку кенеш" in text or "верховный совет" in text or "мыйзам чыгаруу" in text:
codes.add("parliament")
elif "кабинет министров" in text or "правительство" in text or "өкмөт" in text:
codes.add("cabinet")
elif "министер" in text or "мамлекеттик комитет" in text:
codes.add("ministries_and_committees")
elif "административ" in text:
codes.add("administrative_agencies")
elif "национальн" in text and "банк" in text or "улуттук банк" in text:
codes.add("national_bank")
elif "кенеш" in text or "кеңеш" in text or "айыл" in text or "местного самоуправления" in text:
codes.add("local_representative_bodies")
elif "иные государственные" in text or "башка мамлекеттик" in text:
codes.add("other_state_bodies")
else:
codes.add("other")
return sorted(codes) or ["other"]
def legal_force_code(document_type: dict | None) -> str | None:
return LEGAL_FORCE_BY_DOCUMENT_TYPE.get(source_code("document_type", document_type))
+1 -1
View File
@@ -82,7 +82,7 @@ def main() -> int:
raise SystemExit("--top-k must be greater than zero")
result = evaluate(load_queries(arguments.relevance_set), arguments.url, arguments.index, arguments.top_k)
print(json.dumps(result, ensure_ascii=False, indent=2))
print(f"Akyldash Backend v{APP_VERSION} · Frontend — not created", file=sys.stderr)
print(f"Akyldash · Backend v{APP_VERSION} · Frontend v0.3.4", file=sys.stderr)
return 0
@@ -12,6 +12,7 @@
"schema_version": { "type": "keyword" },
"document_code": { "type": "keyword" },
"edition_code": { "type": "keyword" },
"is_current_edition": { "type": "boolean" },
"language": { "type": "keyword" },
"position": { "type": "integer" },
"fragment_type": { "type": "keyword" },
@@ -21,12 +22,16 @@
"document_name_ky": { "type": "text", "analyzer": "icu_analyzer", "fields": { "keyword": { "type": "keyword", "ignore_above": 1024 } } },
"document_type_ru": { "type": "keyword" },
"document_type_ky": { "type": "keyword" },
"document_type_code": { "type": "keyword" },
"legal_force_code": { "type": "keyword" },
"status_ru": { "type": "keyword" },
"status_ky": { "type": "keyword" },
"status_code": { "type": "keyword" },
"number": { "type": "keyword" },
"date_adopted": { "type": "date", "format": "strict_date" },
"authority_paths_ru": { "type": "keyword", "ignore_above": 2048 },
"authority_paths_ky": { "type": "keyword", "ignore_above": 2048 },
"authority_codes": { "type": "keyword" },
"source_path": { "type": "keyword", "index": false },
"source_sha256": { "type": "keyword", "index": false },
"text_sha256": { "type": "keyword", "index": false }
+122 -18
View File
@@ -15,7 +15,9 @@ import urllib.request
from pathlib import Path
from typing import Iterator
APP_VERSION = "0.6.0"
from search.catalog import authority_codes, legal_force_code, source_code
APP_VERSION = "0.13.4"
LANGUAGES = {"ru", "ky"}
DEFAULT_MAPPING = Path(__file__).with_name("minjust-fragments-index.json")
@@ -42,7 +44,7 @@ def paths(document: dict, field: str, language: str) -> list[str]:
return [" > ".join(item[language]) for item in document.get(field, []) if item.get(language)]
def search_document(document: dict, fragment: dict, expected: tuple[str, str, str, int]) -> dict:
def search_document(document: dict, fragment: dict, expected: tuple[str, str, str, int], current_edition: str) -> dict:
document_code, edition_code, language, position = expected
fragment_id = f"document:{document_code}:edition:{edition_code}:lang:{language}:fragment:{position}"
required = ("id", "document_code", "edition_code", "language", "position", "type", "text", "text_sha256", "source_path", "source_sha256")
@@ -64,6 +66,7 @@ def search_document(document: dict, fragment: dict, expected: tuple[str, str, st
"schema_version": document["schema_version"],
"document_code": document_code,
"edition_code": edition_code,
"is_current_edition": edition_code == current_edition,
"language": language,
"position": position,
"fragment_type": fragment["type"],
@@ -72,12 +75,16 @@ def search_document(document: dict, fragment: dict, expected: tuple[str, str, st
"document_name_ky": localized(document.get("name"), "ky"),
"document_type_ru": localized(document.get("type"), "ru"),
"document_type_ky": localized(document.get("type"), "ky"),
"document_type_code": source_code("document_type", document.get("type")),
"legal_force_code": legal_force_code(document.get("type")),
"status_ru": localized(document.get("status"), "ru"),
"status_ky": localized(document.get("status"), "ky"),
"status_code": source_code("status", document.get("status")),
"number": document.get("number"),
"date_adopted": dates.get("DateAdopted"),
"authority_paths_ru": paths(document, "authority_paths", "ru"),
"authority_paths_ky": paths(document, "authority_paths", "ky"),
"authority_codes": authority_codes(document.get("authority_paths", [])),
"source_path": fragment["source_path"],
"source_sha256": fragment["source_sha256"],
"text_sha256": fragment["text_sha256"],
@@ -118,13 +125,23 @@ def bulk_pairs(
document = read_json(directory / "document.json")
if document.get("source_code") != directory.name:
raise ValueError(f"Document identity does not match its path: {directory}")
edition_root = directory / "editions"
editions = [path.name for path in edition_root.iterdir() if path.is_dir()] if edition_root.is_dir() else []
if not editions:
continue
official_editions = document.get("editions") or []
current_edition = (
str(official_editions[-1]["source_code"])
if official_editions
else max(editions, key=lambda value: (not value.isdigit(), int(value) if value.isdigit() else value))
)
for fragment_path in sorted(directory.glob("editions/*/*/fragments.json")):
edition_code, language = fragment_path.parts[-3:-1]
values = read_json(fragment_path)
if not isinstance(values, list):
raise ValueError(f"Fragments must be a list: {fragment_path}")
for position, fragment in enumerate(values, 1):
source = search_document(document, fragment, (directory.name, edition_code, language, position))
source = search_document(document, fragment, (directory.name, edition_code, language, position), current_edition)
action = json.dumps({"index": {"_index": index, "_id": fragment["id"]}}, ensure_ascii=False, allow_nan=False)
body = json.dumps(source, ensure_ascii=False, allow_nan=False)
yield directory.name, f"{action}\n{body}\n".encode()
@@ -325,10 +342,17 @@ def load_bulk(
resume: bool = False,
checkpoint: Path = Path("data/opensearch/minjust-fragments.checkpoint.json"),
alias: str | None = None,
expected_old_index: str | None = None,
canary_ru: str | None = None,
canary_ky: str | None = None,
) -> tuple[int, int]:
base = url.rstrip("/")
if alias is not None and (not alias or alias == index):
raise ValueError("--alias must differ from --index")
if alias is not None and (not expected_old_index or expected_old_index == index):
raise ValueError("alias publication requires an exact --expected-old-index")
if alias is not None and (not canary_ru or not canary_ky):
raise ValueError("alias publication requires both Russian and Kyrgyz canary queries")
index_url = f"{base}/{urllib.parse.quote(index, safe='')}"
if resume:
cluster_uuid, index_uuid = opensearch_identity(base, index, index_url)
@@ -347,6 +371,7 @@ def load_bulk(
alias,
)
documents = document_count(input_root, limit, start_at)
expected_fragments = sum(1 for _ in bulk_pairs(input_root, index, limit)) if alias else None
if not resume:
write_json_atomic(checkpoint, state)
fragments = 0
@@ -381,27 +406,100 @@ def load_bulk(
write_json_atomic(checkpoint, state)
print(f"checkpoint={last_code} fragments={fragments}", flush=True)
if alias:
switch_alias(base, index, alias)
if not expected_fragments:
raise ValueError("refusing to publish an index with an empty expected fragment inventory")
validate_candidate_index(base, index, expected_fragments, canary_ru, canary_ky)
switch_alias(base, index, alias, expected_old_index)
state["complete"] = True
write_json_atomic(checkpoint, state)
return documents, fragments
def switch_alias(base: str, index: str, alias: str) -> None:
def alias_target(base: str, alias: str) -> str:
alias_url = f"{base.rstrip('/')}/_alias/{urllib.parse.quote(alias, safe='')}"
try:
result = request_json(alias_url, "GET", None, "application/json")
except RuntimeError as error:
if "failed with HTTP 404:" in str(error):
raise ValueError(f"alias {alias} has no current target; explicit bootstrap is required") from error
raise
if not isinstance(result, dict) or len(result) != 1:
raise ValueError(f"alias {alias} must point to exactly one index")
target, definition = next(iter(result.items()))
if not isinstance(definition, dict) or alias not in definition.get("aliases", {}):
raise ValueError(f"alias {alias} response is malformed")
return target
def validate_candidate_index(
base: str, index: str, expected_fragments: int, canary_ru: str, canary_ky: str
) -> None:
if expected_fragments <= 0:
raise ValueError("expected fragment count must be positive")
encoded_index = urllib.parse.quote(index, safe='')
refreshed = request_json(f"{base.rstrip('/')}/{encoded_index}/_refresh", "POST", None, "application/json")
shards = refreshed.get("_shards", {})
if not isinstance(shards, dict) or shards.get("failed") != 0:
raise RuntimeError(f"refresh failed for candidate index {index}")
count_result = request_json(f"{base.rstrip('/')}/{encoded_index}/_count", "GET", None, "application/json")
if count_result.get("count") != expected_fragments:
raise RuntimeError(
f"candidate index {index} has {count_result.get('count')} fragments; expected {expected_fragments}"
)
for language, query in (("ru", canary_ru), ("ky", canary_ky)):
payload = json.dumps({"size": 1, "query": {"match": {f"text_{language}": query}}}).encode()
result = request_json(
f"{base.rstrip('/')}/{encoded_index}/_search", "POST", payload, "application/json"
)
hits = result.get("hits", {}).get("hits", [])
if not hits or not isinstance(hits[0].get("_source", {}).get(f"text_{language}"), str):
raise RuntimeError(f"candidate index {index} failed the {language} canary query")
def switch_alias(base: str, index: str, alias: str, expected_old_index: str) -> None:
if not alias or alias == index:
raise ValueError("--alias must differ from --index")
result = request_json(
f"{base.rstrip('/')}/_aliases",
"POST",
json.dumps({
"actions": [
{"remove": {"index": "*", "alias": alias, "must_exist": False}},
{"add": {"index": index, "alias": alias}},
]
}).encode(),
"application/json",
)
if result.get("acknowledged") is not True:
if not expected_old_index or expected_old_index == index:
raise ValueError("--expected-old-index must name the exact previous index")
actual_old_index = alias_target(base, alias)
if actual_old_index == index:
return
if actual_old_index != expected_old_index:
raise ValueError(
f"alias {alias} currently targets {actual_old_index}, expected {expected_old_index}; refusing cutover"
)
try:
result = request_json(
f"{base.rstrip('/')}/_aliases",
"POST",
json.dumps({
"actions": [
{"remove": {"index": expected_old_index, "alias": alias, "must_exist": True}},
{"add": {"index": index, "alias": alias}},
]
}).encode(),
"application/json",
)
except Exception as error:
try:
current = alias_target(base, alias)
except Exception:
raise RuntimeError(f"alias switch outcome is unknown; inspect {alias} before resuming") from error
if current == index:
return
if current != expected_old_index:
raise ValueError(
f"alias {alias} now targets unexpected index {current}; refusing to retry publication"
) from error
raise RuntimeError(f"alias switch failed; {alias} still targets {expected_old_index}") from error
if not isinstance(result, dict) or result.get("acknowledged") is not True:
current = alias_target(base, alias)
if current == index:
return
if current != expected_old_index:
raise ValueError(
f"alias {alias} now targets unexpected index {current}; refusing to retry publication"
)
raise RuntimeError(f"OpenSearch did not acknowledge alias switch: {alias}")
@@ -413,6 +511,9 @@ def main() -> int:
parser.add_argument("--limit", type=int)
parser.add_argument("--url", help="create the index and stream bounded Bulk requests instead of writing a file")
parser.add_argument("--alias", help="atomically point this alias at --index after a successful load")
parser.add_argument("--expected-old-index", help="exact current alias target required for guarded cutover")
parser.add_argument("--canary-ru", help="approved Russian query that must match the candidate index")
parser.add_argument("--canary-ky", help="approved Kyrgyz query that must match the candidate index")
parser.add_argument("--mapping", type=Path, default=DEFAULT_MAPPING)
parser.add_argument("--batch-mb", type=int, default=25)
parser.add_argument("--resume", action="store_true", help="load into an existing index")
@@ -443,12 +544,15 @@ def main() -> int:
arguments.resume,
checkpoint,
arguments.alias,
arguments.expected_old_index,
arguments.canary_ru,
arguments.canary_ky,
)
destination = arguments.url
else:
documents, fragments = export_bulk(arguments.input, arguments.output, arguments.index, arguments.limit)
destination = arguments.output
print(f"documents={documents} fragments={fragments} destination={destination}\nAkyldash Backend v{APP_VERSION} · Frontend — not created")
print(f"documents={documents} fragments={fragments} destination={destination}\nAkyldash · Backend v{APP_VERSION} · Frontend v0.3.4")
return 0
+180 -27
View File
@@ -10,23 +10,99 @@ import urllib.parse
from search.minjust_opensearch import APP_VERSION, request_json
def company_registration_clauses(language: str, query: str) -> list[dict]:
patterns = {
"ru": (r"\bкак\s+откры\w*\s+осоо\b", r"\b(порядок|процедура)\s+откры\w*\s+осоо\b", r"\bкак\s+зарегистр\w*\s+осоо\b"),
"ky": (r"\bжчк\s+ач\w*\s+тартиби\b", r"\bжчк\s+кантип\s+ач\w*\b"),
}[language]
if not any(re.search(pattern, query.casefold()) for pattern in patterns):
return []
LEGAL_FORM_ALIASES = {
"ru": {
"осоо": ("ооо", "общество с ограниченной ответственностью"),
"ооо": ("осоо", "общество с ограниченной ответственностью"),
"общество с ограниченной ответственностью": ("осоо", "ооо"),
"общества с ограниченной ответственностью": ("осоо", "ооо"),
},
"ky": {
"жчк": ("жоопкерчилиги чектелген коом",),
"жоопкерчилиги чектелген коом": ("жчк",),
},
}
# The indexed Civil Code uses the legal term "имущественный наем" for a
# договор аренды. Keep this exact legal phrase expansion narrow: it must not
# rewrite residential hiring, employment, sublease, or leasing queries.
LEASE_TERM_ALIAS = re.compile(
r"(?<!\w)(договор(?:а|у|ом|е)?)[ \t]+аренд(?:а|ы|е|у|ой|ою)(?!\w)",
re.IGNORECASE,
)
LEASE_TERM_ALIAS_EXCLUSIONS = re.compile(
r"\b(?:квартир\w*|жил\w*|дом\w*|помещен\w*|земл\w*|участк\w*|"
r"поднайм\w*|подна[её]м\w*|субаренд\w*|лизинг\w*|труд\w*|работник\w*)\b",
re.IGNORECASE,
)
LEASE_TERM_PHRASE_BOOST = 12
COMPANY_REGISTRATION_DOCUMENTS = (("230044970", 3000), ("667", 2000), ("4", 1000))
TAX_PROFIT_DOCUMENTS = (("112340", 3000),)
BOILERPLATE_PREFIXES = {
"ru": (
"как регулируется", "правовое регулирование", "судебная практика", "сроки и ответственность",
"действующая редакция", "последние изменения", "основные положения", "комментарий к акту",
),
"ky": (),
}
ACT_TITLE_PREFIXES = {
"ru": (
r"(?:гражданский\s+)?процессуальный\s+кодекс(?:\s+(?:кыргызской\s+республики|кр))?",
r"(?:гражданск\w*|уголовн\w*|налогов\w*|трудов\w*|семейн\w*)\s+кодекс\w*(?:\s+(?:кыргызской\s+республики|кр))?",
r"кодекс\s+о\s+правонарушениях(?:\s+(?:кыргызской\s+республики|кр))?",
r"конституци\w*(?:\s+(?:кыргызской\s+республики|кр))?",
r"(?:гк|ук|нк|коап|гпк|упк|уик)(?:\s+(?:кыргызской\s+республики|кр))?",
r"закон(?:\s+(?:кыргызской\s+республики|кр))?\s+о\s+(?:защит\w*\s+прав\w*\s+потребител\w*|государственн\w+\s+регистраци\w+\s+юридическ\w+\s+лиц|труд\w*)",
),
"ky": (
r"кыргыз\w*\s+республикасынын\s+конституци\w*",
r"(?:кыргыз\w*\s+республикасынын\s+)?(?:жарандык|кылмыш\w*|салык|эмгек|үй-бүлө|административдик|жарандык\s+процесстик)\s+кодекс\w*",
r"(?:кыргыз\w*\s+республикасынын\s+)?(?:жк|үк)(?:\s+кр)?",
),
}
def relaxed_query_text(language: str, query: str) -> str:
value = query.strip()
for prefix in BOILERPLATE_PREFIXES[language]:
if value.casefold().startswith(prefix):
value = value[len(prefix):].strip()
break
for pattern in ACT_TITLE_PREFIXES[language]:
match = re.match(rf"\s*{pattern}(?=\s|$|[.,;:!?])", value, re.IGNORECASE)
if match:
value = value[match.end():].lstrip(" \t,;:—-")
break
return value
def lease_term_variant(query: str) -> str | None:
if LEASE_TERM_ALIAS.search(query) and not LEASE_TERM_ALIAS_EXCLUSIONS.search(query):
return LEASE_TERM_ALIAS.sub(r"\1 имущественного найма", query)
return None
def query_variants(language: str, query: str) -> list[str]:
variants = [query]
for alias, expansions in LEGAL_FORM_ALIASES[language].items():
pattern = re.compile(rf"(?<!\w){re.escape(alias)}(?!\w)", re.IGNORECASE)
if pattern.search(query):
variants.extend(pattern.sub(expansion, query) for expansion in expansions)
if language == "ru":
alias = lease_term_variant(query)
if alias:
variants.append(alias)
return list(dict.fromkeys(variants))
def document_priority_clauses(language: str, documents: tuple[tuple[str, int], ...]) -> list[dict]:
status = {"ru": "Действует", "ky": "Күчүндө"}[language]
# ponytail: curated legal mapping; replace with a reviewed intent catalog when coverage expands.
def clause(document_code: str, boost: int) -> dict:
return {
return [
{
"constant_score": {
"filter": {
"bool": {
"filter": [
{"term": {"document_code": document_code}},
{"term": {"document_code": code}},
{"term": {f"status_{language}": status}},
]
}
@@ -34,29 +110,106 @@ def company_registration_clauses(language: str, query: str) -> list[dict]:
"boost": boost,
}
}
for code, boost in documents
]
return [clause("230044970", 2000), clause("667", 1000)]
def company_registration_clauses(language: str, query: str) -> list[dict]:
non_registration_context = {
"ru": (r"\bсч[её]т\w*", r"\bбанк\w*", r"\bфилиал\w*", r"\bпредставительств\w*"),
"ky": (r"\bбанк\w*", r"\bэсеп\w*", r"\bфилиал\w*", r"\bөкүлчүлүк\w*"),
}[language]
normalized_query = query.casefold()
if any(re.search(pattern, normalized_query) for pattern in non_registration_context):
return []
forms, patterns = {
"ru": (
r"(?:осоо|ооо|обществ\w*\s+с\s+ограниченной\s+ответственностью)",
(
r"\bкак\s+(?:откры\w*|созда\w*|зарегистр\w*)\s+{form}\b",
r"\b(?:порядок|процедура)\s+(?:открыти\w*|создани\w*|регистраци\w*)\s+{form}\b",
r"\b(?:государственн\w+\s+)?регистраци\w*\s+{form}\b",
r"\bсоздани\w*\s+{form}\b",
),
),
"ky": (
r"(?:жчк|жоопкерчилиги\s+чектелген\s+коом)",
(
r"\bжчк\s+ач\w*\s+тартиби\b",
r"\bжчк\s+кантип\s+ач\w*\b",
r"\b{form}\s+(?:ачуу|ачуунун|каттоо|каттоонун)\b",
r"\b(?:ачуу|каттоо)\s+{form}\b",
),
),
}[language]
if not any(re.search(pattern.format(form=forms), normalized_query) for pattern in patterns):
return []
return document_priority_clauses(language, COMPANY_REGISTRATION_DOCUMENTS)
def tax_profit_clauses(language: str, query: str) -> list[dict]:
if language != "ru" or not re.search(r"\bналог\w*\s+(?:на\s+)?прибыл\w*\b", query.casefold()):
return []
# ponytail: exact topic anchor; expand only with verified tax-code sources.
return document_priority_clauses(language, TAX_PROFIT_DOCUMENTS)
def build_search_query(language: str, query: str) -> dict:
registration_clauses = company_registration_clauses(language, query)
intent_clauses = [*registration_clauses, *tax_profit_clauses(language, query)]
variants = query_variants(language, query)
full_text = [
{
"multi_match": {
"query": variant,
"fields": [f"document_name_{language}^4", f"text_{language}"],
"type": "cross_fields",
"operator": "OR" if registration_clauses else "AND",
}
}
for variant in variants
]
relaxed_text = []
if not registration_clauses:
seen_relaxed = set()
for variant in variants:
value = relaxed_query_text(language, variant)
words = value.split()
if value in seen_relaxed or len(words) < 2 or (value == variant.strip() and len(words) < 3):
continue
if len(words) < 3 and not any(len(word) >= 5 for word in words):
continue
seen_relaxed.add(value)
relaxed_text.append({
"multi_match": {
"query": value,
"fields": [f"document_name_{language}^4", f"text_{language}"],
"type": "cross_fields",
"operator": "OR",
"minimum_should_match": 2,
"boost": 0.1,
}
})
lease_variant = lease_term_variant(query) if language == "ru" else None
phrase_matches = [
{"match_phrase": {f"text_{language}": {
"query": variant,
"boost": LEASE_TERM_PHRASE_BOOST if variant == lease_variant else 3,
}}}
for variant in variants
]
planned = {"should": [*full_text, *phrase_matches, *relaxed_text, *intent_clauses], "minimum_should_match": 1}
return {"bool": planned}
def build_search_body(language: str, query: str, top_k: int) -> bytes:
full_text = {
"multi_match": {
"query": query,
"fields": [f"document_name_{language}", f"text_{language}"],
"type": "cross_fields",
}
}
clauses = company_registration_clauses(language, query)
bool_query = {"filter": {"term": {"language": language}}}
if clauses:
bool_query.update({"should": [full_text, *clauses], "minimum_should_match": 1})
else:
bool_query["must"] = full_text
return json.dumps({
"size": top_k,
"track_total_hits": False,
"_source": ["document_code"],
"query": {"bool": bool_query},
"query": {"bool": {"filter": [{"term": {"language": language}}, {"term": {"is_current_edition": True}}], "must": build_search_query(language, query)}},
"collapse": {"field": "document_code"},
}, ensure_ascii=False).encode()
@@ -82,7 +235,7 @@ def main() -> int:
if arguments.top_k <= 0:
raise SystemExit("--top-k must be greater than zero")
print(json.dumps(search_documents(arguments.url, arguments.index, arguments.language, arguments.query, arguments.top_k), ensure_ascii=False))
print(f"Akyldash Backend v{APP_VERSION} · Frontend — not created")
print(f"Akyldash · Backend v{APP_VERSION} · Frontend v0.3.4")
return 0
File diff suppressed because it is too large. Load diff
+83
View File
@@ -0,0 +1,83 @@
"""Persistence and signed snapshots for search relevance reviews."""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import secrets
import sqlite3
import threading
import time
from pathlib import Path
MAX_COMMENT = 4000
MAX_REVIEWER = 120
class ReviewStore:
def __init__(self, path: Path | str = ":memory:"):
if path != ":memory:":
Path(path).parent.mkdir(parents=True, exist_ok=True)
self.connection = sqlite3.connect(path, check_same_thread=False)
self.connection.row_factory = sqlite3.Row
# ponytail: one SQLite lock; split connections only if review throughput matters.
self._lock = threading.Lock()
self.connection.execute("""
CREATE TABLE IF NOT EXISTS search_reviews (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
reviewer TEXT NOT NULL,
query TEXT NOT NULL,
language TEXT NOT NULL,
index_name TEXT NOT NULL,
algorithm_version TEXT NOT NULL,
top_result_code TEXT,
results_json TEXT NOT NULL,
overall_comment TEXT NOT NULL
)
""")
self.connection.commit()
def save(self, review: dict) -> int:
with self._lock:
cursor = self.connection.execute(
"INSERT INTO search_reviews(created_at, reviewer, query, language, index_name, algorithm_version, top_result_code, results_json, overall_comment) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)",
(review["created_at"], review["reviewer"], review["query"], review["language"], review["index_name"], review["algorithm_version"], review["top_result_code"], json.dumps(review["results"], ensure_ascii=False), review["overall_comment"]),
)
self.connection.commit()
return int(cursor.lastrowid)
def export(self) -> list[dict]:
with self._lock:
return [
{**dict(row), "results": json.loads(row["results_json"])}
for row in self.connection.execute("SELECT * FROM search_reviews ORDER BY id")
]
class ReviewSnapshots:
def __init__(self, secret: bytes | None = None, ttl: int = 3600):
self.secret = secret or secrets.token_bytes(32)
self.ttl = ttl
def create(self, query: str, language: str, index: str, results: list[dict], algorithm_version: str) -> str:
payload = {"query": query, "language": language, "index_name": index, "algorithm_version": algorithm_version, "results": results, "expires_at": int(time.time()) + self.ttl}
encoded = base64.urlsafe_b64encode(json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode()).decode().rstrip("=")
signature = hmac.new(self.secret, encoded.encode(), hashlib.sha256).hexdigest()
return f"{encoded}.{signature}"
def verify(self, token: str) -> dict:
try:
encoded, signature = token.split(".", 1)
expected = hmac.new(self.secret, encoded.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(signature, expected):
raise ValueError
payload = json.loads(base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4)))
if payload["expires_at"] < int(time.time()):
raise ValueError
return payload
except (ValueError, KeyError, TypeError, json.JSONDecodeError, UnicodeError) as error:
raise ValueError("invalid or expired search snapshot") from error
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
<circle cx="24" cy="24" r="20" fill="#F7F2E8" stroke="#9D2633" stroke-width="3"/>
<path d="M24 5v38M5 24h38M11 11l26 26M37 11 11 37" fill="none" stroke="#9D2633" stroke-width="3" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 276 B

@@ -0,0 +1,93 @@
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic) NotoSans-Italic[wdth,wght].ttf: Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,93 @@
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic) NotoSerif-Italic[wdth,wght].ttf: Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+405
View File
@@ -0,0 +1,405 @@
import json
import os
import sqlite3
import tempfile
import unittest
import urllib.error
from email.message import Message
from pathlib import Path
from unittest.mock import Mock, patch
from ingestion import minjust_current
from ingestion.minjust_current import (
ApiResponse,
CurrentCbdClient,
SourceChanged,
SourceError,
stage_document,
)
def api_response(value):
body = json.dumps(value, ensure_ascii=False).encode()
import hashlib
return ApiResponse(value, body, hashlib.sha256(body).hexdigest())
def document_record(alias="6", canonical=6, editions=None):
return api_response(
{
"documentCode": canonical,
"nameRus": "Закон",
"nameKyr": "Мыйзам",
"headerRu": "Заголовок",
"headerKg": "Баш сөз",
"number": 114,
"dateAdopted": "1998-08-04",
"classId": 1,
"refTypeId": 10,
"status": 1,
"editions": editions if editions is not None else [
{"id": 920492, "nameRus": "Редакция A", "nameKyr": "Редакция A", "textRusType": "docx", "textKyrType": "docx"},
{"id": 920292, "nameRus": "Редакция B", "nameKyr": "Редакция B", "textRusType": "docx", "textKyrType": "docx"},
],
}
)
class CurrentCbdClientTests(unittest.TestCase):
def test_rejects_legacy_roots_when_called_from_an_unrelated_working_directory(self):
with tempfile.TemporaryDirectory() as temporary:
base = Path(temporary)
repository = base / "repository"
elsewhere = base / "elsewhere"
elsewhere.mkdir()
legacy_roots = [
repository / "data/minjust-cbd",
repository / "data/minjust-normalized",
]
for legacy in legacy_roots:
legacy.mkdir(parents=True)
# Root CI uses umask 000, so make the trusted fixture's parent permissions explicit.
for directory in (repository, repository / "data", *legacy_roots):
directory.chmod(0o755)
original_cwd = Path.cwd()
client = Mock()
try:
os.chdir(elsewhere)
with patch.object(minjust_current, "REPOSITORY_ROOT", repository):
for legacy in legacy_roots:
with self.subTest(legacy=legacy), self.assertRaisesRegex(ValueError, "must not overlap"):
stage_document(legacy / "staging", "6", client)
self.assertFalse((legacy / "staging").exists())
finally:
os.chdir(original_cwd)
client.document.assert_not_called()
def test_requires_private_owned_staging_root(self):
with tempfile.TemporaryDirectory() as temporary:
output = Path(temporary) / "shared"
output.mkdir(mode=0o755)
output.chmod(0o755)
with self.assertRaisesRegex(ValueError, "private directory"):
stage_document(output, "6", Mock())
def test_resume_rejects_symlinked_checkpoints_and_document_paths(self):
with tempfile.TemporaryDirectory() as temporary:
base = Path(temporary)
targets = [
("request-contract.json", False),
("current-api.sqlite3", False),
("manifest.sqlite3", False),
(".work/documents/6", True),
(".work/documents/6/editions/920492/ru.html", False),
("documents/6", True),
]
client = Mock()
for index, (relative, directory) in enumerate(targets):
with self.subTest(relative=relative):
output = base / f"generation-{index}"
output.mkdir(mode=0o700)
contract = output / "request-contract.json"
if relative != "request-contract.json":
contract.write_text(json.dumps(minjust_current.REQUEST_CONTRACT), encoding="utf-8")
outside = base / f"outside-{index}"
if directory:
outside.mkdir()
else:
outside.write_text("preserve", encoding="utf-8")
link = output / relative
link.parent.mkdir(parents=True, exist_ok=True)
try:
link.symlink_to(outside, target_is_directory=directory)
except (NotImplementedError, OSError):
self.skipTest("symlinks are unavailable")
with self.assertRaisesRegex(ValueError, "symbolic link"):
stage_document(output, "6", client, resume=True)
client.document.assert_not_called()
if not directory:
self.assertEqual(outside.read_text(encoding="utf-8"), "preserve")
else:
self.assertEqual(list(outside.iterdir()), [])
def test_respects_retry_after_and_retries_server_errors(self):
headers = Message()
headers["Retry-After"] = "7"
response = Mock()
response.read.return_value = b"{}"
response.__enter__ = Mock(return_value=response)
response.__exit__ = Mock(return_value=False)
opener = Mock(side_effect=[
urllib.error.HTTPError("https://example.test", 429, "", headers, None),
urllib.error.HTTPError("https://example.test", 503, "", Message(), None),
response,
])
delays = []
CurrentCbdClient(opener=opener, sleep=delays.append, clock=lambda: 0, retries=3).request_json("/GetEdition")
self.assertIn(7.0, delays)
self.assertIn(2, delays)
self.assertEqual(opener.call_count, 3)
def test_enforces_request_interval(self):
now = [0.0]
starts = []
response = Mock()
response.read.return_value = b"{}"
response.__enter__ = Mock(return_value=response)
response.__exit__ = Mock(return_value=False)
def opener(_request, timeout):
starts.append(now[0])
return response
client = CurrentCbdClient(
opener=opener,
clock=lambda: now[0],
sleep=lambda delay: now.__setitem__(0, now[0] + delay),
)
client.request_json("/GetEdition")
client.request_json("/GetEdition")
self.assertGreaterEqual(starts[1] - starts[0], 1.0)
def test_rejects_oversized_response(self):
response = Mock()
response.read.return_value = b"x" * 11
response.__enter__ = Mock(return_value=response)
response.__exit__ = Mock(return_value=False)
with self.assertRaisesRegex(SourceError, "exceeds 10 bytes"):
CurrentCbdClient(opener=lambda *_args, **_kwargs: response, max_response_bytes=10).request_json("/GetEdition")
def test_get_edition_uses_exact_query_and_checks_requested_id(self):
response = Mock()
response.read.return_value = json.dumps({"id": 920292, "contentRu": "text", "contentKg": None}).encode()
response.__enter__ = Mock(return_value=response)
response.__exit__ = Mock(return_value=False)
requests = []
def opener(request, timeout):
requests.append(request)
return response
result = CurrentCbdClient(opener=opener, sleep=lambda _delay: None).edition(920292, "kg")
self.assertEqual(requests[0].full_url, "https://cbd.minjust.gov.kg/api/v1/GetEdition?editionId=920292&lang=kg&exact=true")
self.assertEqual(requests[0].method, "GET")
self.assertEqual(result.value["id"], 920292)
def test_rejects_response_id_that_does_not_match_exact_request(self):
response = Mock()
response.read.return_value = json.dumps({"id": 920492, "contentRu": "text", "contentKg": None}).encode()
response.__enter__ = Mock(return_value=response)
response.__exit__ = Mock(return_value=False)
with self.assertRaisesRegex(SourceChanged, "does not match requested ID"):
CurrentCbdClient(opener=lambda *_args, **_kwargs: response).edition(920292, "ru")
def test_stages_alias_mapping_language_quirk_and_official_edition_order(self):
with tempfile.TemporaryDirectory() as temporary:
base = Path(temporary)
output = base / "generation"
old_raw = base / "legacy" / "documents" / "6" / "sentinel"
old_raw.parent.mkdir(parents=True)
old_raw.write_text("preserve", encoding="utf-8")
class Client:
def document(self, alias):
self.alias = alias
return document_record(alias=alias, canonical=6)
def edition(self, identifier, language):
text = "<p>Russian</p>" if language == "ru" else "<p>Кыргызча</p>"
name = "Редакция A" if identifier == 920492 else "Редакция B"
return api_response({
"id": identifier, "nameRus": name, "nameKyr": name,
"contentRu": text, "contentKg": None,
})
client = Client()
result = stage_document(output, "6", client)
raw = output / "documents" / "6"
self.assertEqual(result["state"], "text_complete_incomplete_attachments")
self.assertEqual((raw / "editions/920492/ru.html").read_text(), "<p>Russian</p>")
self.assertEqual((raw / "editions/920492/ky.html").read_text(), "<p>Кыргызча</p>")
first = json.loads((raw / "editions/920492/metadata.json").read_text())
second = json.loads((raw / "editions/920292/metadata.json").read_text())
self.assertEqual((first["SourceOrder"], second["SourceOrder"]), (0, 1))
language_meta = json.loads((raw / "editions/920292/source/ru.json").read_text())
self.assertEqual((language_meta["requested_edition_id"], language_meta["exact"]), (920292, True))
with sqlite3.connect(output / "current-api.sqlite3") as connection:
self.assertTrue(connection.execute("SELECT contract_sha256 FROM generation").fetchone()[0])
self.assertEqual(old_raw.read_text(), "preserve")
with sqlite3.connect(output / "current-api.sqlite3") as connection:
rows = connection.execute(
"SELECT request_language, archive_language, state FROM languages ORDER BY request_language, edition_id"
).fetchall()
self.assertEqual(rows, [("kg", "ky", "complete")] * 2 + [("ru", "ru", "complete")] * 2)
def test_resolves_official_alias_to_canonical_staging_directory(self):
with tempfile.TemporaryDirectory() as temporary:
output = Path(temporary) / "generation"
class Client:
def document(self, alias):
return document_record(alias=alias, canonical=205064, editions=[])
result = stage_document(output, "1-4", Client())
self.assertEqual(result["canonical_id"], 205064)
self.assertTrue((output / "documents/205064/metadata.json").is_file())
self.assertFalse((output / "documents/1-4").exists())
def test_rejects_non_exact_edition_response_id_without_publishing(self):
with tempfile.TemporaryDirectory() as temporary:
output = Path(temporary) / "generation"
class Client:
def document(self, _alias):
return document_record(editions=[{"id": 920292, "textRusType": "docx", "textKyrType": "docx"}])
def edition(self, _identifier, language):
return api_response({"id": 920492, "contentRu": f"<{language} edition>", "contentKg": None})
with self.assertRaisesRegex(SourceChanged, "exact edition ID mismatch"):
stage_document(output, "6", Client())
self.assertFalse((output / "documents/6").exists())
with sqlite3.connect(output / "current-api.sqlite3") as connection:
self.assertEqual(connection.execute("SELECT state FROM languages").fetchone()[0], "error")
def test_rejects_exact_edition_metadata_contradiction(self):
with tempfile.TemporaryDirectory() as temporary:
output = Path(temporary) / "generation"
client = CurrentCbdClient()
client.document = lambda _alias: document_record(editions=[{
"id": 920292, "nameRus": "04.08.1998", "nameKyr": "04.08.1998",
"textRusType": "docx", "textKyrType": "docx",
}])
client.request_json = lambda *_args, **_kwargs: api_response({
"id": 920292, "nameRus": "13.04.2017", "nameKyr": "13.04.2017",
"contentRu": "<wrong edition>", "contentKg": None,
})
with self.assertRaisesRegex(SourceChanged, "metadata mismatch"):
stage_document(output, "6", client)
self.assertFalse((output / "documents/6").exists())
def test_recovers_atomic_publish_before_manifest_checkpoint(self):
with tempfile.TemporaryDirectory() as temporary:
output = Path(temporary) / "generation"
class Client:
def document(self, _alias):
return document_record(editions=[{"id": 920492, "textRusType": "docx", "textKyrType": "docx"}])
def edition(self, identifier, language):
return api_response({"id": identifier, "contentRu": f"<{language}>", "contentKg": None})
client = Client()
first = stage_document(output, "6", client)
with sqlite3.connect(output / "current-api.sqlite3") as connection:
connection.execute("UPDATE documents SET state='fetching' WHERE alias='6'")
with sqlite3.connect(output / "manifest.sqlite3") as connection:
connection.execute("DELETE FROM documents WHERE code=6")
connection.execute("DELETE FROM errors WHERE code=6")
resumed = stage_document(output, "6", client, resume=True)
self.assertEqual(resumed["source_sha256"], first["source_sha256"])
self.assertTrue(resumed["resumed"])
with sqlite3.connect(output / "manifest.sqlite3") as connection:
self.assertEqual(connection.execute("SELECT COUNT(*) FROM documents WHERE code=6").fetchone()[0], 1)
self.assertEqual(connection.execute("SELECT COUNT(*) FROM errors WHERE code=6").fetchone()[0], 1)
def test_retries_after_document_metadata_request_failed_before_checkpoint(self):
with tempfile.TemporaryDirectory() as temporary:
output = Path(temporary) / "generation"
class Client:
failed = False
def document(self, _alias):
if not self.failed:
self.failed = True
raise SourceError("temporary DNS failure")
return document_record(editions=[])
client = Client()
with self.assertRaisesRegex(SourceError, "DNS"):
stage_document(output, "6", client)
result = stage_document(output, "6", client, resume=True)
self.assertEqual(result["canonical_id"], 6)
with sqlite3.connect(output / "current-api.sqlite3") as connection:
row = connection.execute(
"SELECT canonical_id, response_sha256, expected_editions FROM documents WHERE alias='6'"
).fetchone()
self.assertEqual(row, (6, document_record(editions=[]).sha256, 0))
def test_does_not_resume_generation_without_exact_request_contract(self):
with tempfile.TemporaryDirectory() as temporary:
output = Path(temporary) / "legacy-generation"
output.mkdir(mode=0o700)
sentinel = output / "old-response.json"
sentinel.write_text("preserve", encoding="utf-8")
client = Mock()
with self.assertRaisesRegex(SourceChanged, "no exact-edition request contract"):
stage_document(output, "6", client, resume=True)
client.document.assert_not_called()
self.assertEqual(sentinel.read_text(encoding="utf-8"), "preserve")
def test_incomplete_translation_resumes_without_rewriting_successful_language(self):
with tempfile.TemporaryDirectory() as temporary:
output = Path(temporary) / "generation"
class Client:
failed = False
def document(self, _alias):
return document_record(editions=[{"id": 920492, "textRusType": "docx", "textKyrType": "docx"}])
def edition(self, identifier, language):
if language == "kg" and not self.failed:
self.failed = True
raise SourceError("simulated interrupted response")
return api_response({"id": identifier, "contentRu": f"<{language}>", "contentKg": None})
client = Client()
first = stage_document(output, "6", client)
self.assertIn("incomplete", first)
self.assertFalse((output / "documents/6").exists())
second = stage_document(output, "6", client, resume=True)
self.assertEqual(second["state"], "text_complete_incomplete_attachments")
self.assertEqual((output / "documents/6/editions/920492/ru.html").read_text(), "<ru>")
self.assertEqual((output / "documents/6/editions/920492/ky.html").read_text(), "<kg>")
self.assertEqual(client.failed, True)
def test_refuses_budget_overrun_without_publishing_partial_document(self):
with tempfile.TemporaryDirectory() as temporary:
output = Path(temporary) / "generation"
class Client:
def document(self, _alias):
return document_record(editions=[{"id": 920492, "textRusType": "docx", "textKyrType": "docx"}])
def edition(self, identifier, _language):
return api_response({"id": identifier, "contentRu": "123456", "contentKg": None})
with self.assertRaisesRegex(SourceError, "budget"):
stage_document(output, "6", Client(), max_document_bytes=5)
self.assertFalse((output / "documents/6").exists())
if __name__ == "__main__":
unittest.main()
+41
View File
@@ -86,6 +86,47 @@ class MinjustNormalizationTest(unittest.TestCase):
empty = json.loads((output / "documents/1/editions/40/edition.json").read_text(encoding="utf-8"))
self.assertFalse(empty["quality"]["has_html"])
def test_preserves_official_edition_order_instead_of_sorting_ids(self):
with tempfile.TemporaryDirectory() as temporary:
base = Path(temporary)
source = base / "source"
output = base / "normalized"
document = self.document(source, 6)
self.edition(document, 920492, {"ru": "<p>Первая редакция</p>"})
self.edition(document, 920292, {"ru": "<p>Последняя редакция</p>"})
for code, order in ((920492, 0), (920292, 1)):
metadata_path = document / "editions" / str(code) / "metadata.json"
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
metadata["SourceOrder"] = order
metadata_path.write_text(json.dumps(metadata, ensure_ascii=False), encoding="utf-8")
normalize_archive(source, output)
normalized = json.loads((output / "documents/6/document.json").read_text(encoding="utf-8"))
self.assertEqual([item["source_code"] for item in normalized["editions"]], ["920492", "920292"])
self.assertEqual([item["source_order"] for item in normalized["editions"]], [0, 1])
def test_rejects_partial_source_edition_order(self):
with tempfile.TemporaryDirectory() as temporary:
base = Path(temporary)
source = base / "source"
output = base / "normalized"
document = self.document(source, 6)
self.edition(document, 10, {"ru": "<p>Один</p>"})
self.edition(document, 20, {"ru": "<p>Два</p>"})
metadata_path = document / "editions/10/metadata.json"
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
metadata["SourceOrder"] = 0
metadata_path.write_text(json.dumps(metadata), encoding="utf-8")
with self.assertLogs("normalization.minjust_cbd", level="ERROR"):
result = normalize_archive(source, output)
self.assertEqual((result.failed, result.normalized), (1, 0))
with sqlite3.connect(output / "manifest.sqlite3") as connection:
error = connection.execute("SELECT error FROM documents WHERE code='6'").fetchone()[0]
self.assertIn("Invalid source edition order", error)
def test_continues_after_bad_document_and_clears_repaired_error(self):
with tempfile.TemporaryDirectory() as temporary:
base = Path(temporary)
+269 -19
View File
@@ -9,26 +9,239 @@ from contextlib import closing
from pathlib import Path
from unittest.mock import patch
from search.catalog import legal_force_code, labels, source_code
from search.minjust_opensearch import bulk_batches, document_codes, export_bulk, load_bulk, request_json, switch_alias
class MinjustOpenSearchTest(unittest.TestCase):
def test_switches_alias_atomically_after_successful_load(self):
with patch("search.minjust_opensearch.request_json", return_value={"acknowledged": True}) as request:
switch_alias("http://127.0.0.1:9200/", "akyldash-fragments-v2", "akyldash-fragments-current")
def test_unknown_document_type_keeps_source_label_and_has_no_inferred_force(self):
value = {"ru": "Решение", "ky": "Решение"}
self.assertEqual(request.call_args.args[:2], ("http://127.0.0.1:9200/_aliases", "POST"))
body = json.loads(request.call_args.args[2])
self.assertEqual(body["actions"][0], {"remove": {"index": "*", "alias": "akyldash-fragments-current", "must_exist": False}})
self.assertEqual(source_code("document_type", value), "other")
self.assertEqual(labels("document_type", "other"), {"ru": "Прочие документы", "ky": "Башка документтер"})
self.assertIsNone(legal_force_code(value))
self.assertEqual(source_code("status", {"ru": "Не вступил в силу", "ky": "Күчүнө кире элек"}), "not_yet_effective")
self.assertEqual(legal_force_code({"ru": "Конституционный закон", "ky": "Конституционный закон"}), "constitutional")
self.assertEqual(legal_force_code({"ru": "Указ", "ky": "Жарлыгы"}), "subordinate")
self.assertEqual(source_code("document_type", {"ru": "устав"}), "charter")
def test_export_marks_last_official_edition_current_even_when_ids_are_out_of_order(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
input_root = root / "normalized"
document_root = input_root / "documents/6"
document_root.mkdir(parents=True)
with closing(sqlite3.connect(input_root / "manifest.sqlite3")) as connection:
with connection:
connection.execute("CREATE TABLE documents (code TEXT, state TEXT)")
connection.execute("INSERT INTO documents VALUES ('6', 'success')")
(document_root / "document.json").write_text(
json.dumps({"schema_version": "1", "source_code": "6", "editions": [
{"source_code": "920492", "source_order": 0},
{"source_code": "920292", "source_order": 1},
]}),
encoding="utf-8",
)
for edition in ("920292", "920492"):
text = f"Текст {edition}"
fragment_path = document_root / f"editions/{edition}/ru/fragments.json"
fragment_path.parent.mkdir(parents=True)
fragment_path.write_text(json.dumps([{
"id": f"document:6:edition:{edition}:lang:ru:fragment:1",
"document_code": "6",
"edition_code": edition,
"language": "ru",
"position": 1,
"type": "paragraph",
"text": text,
"text_sha256": hashlib.sha256(text.encode()).hexdigest(),
"source_path": f"documents/6/editions/{edition}/ru.html",
"source_sha256": "b" * 64,
}]), encoding="utf-8")
output = root / "bulk.ndjson"
self.assertEqual(export_bulk(input_root, output, "test-index"), (1, 2))
lines = [json.loads(line) for line in output.read_text(encoding="utf-8").splitlines()]
flags = {
lines[index]["index"]["_id"].split(":")[3]: lines[index + 1]["is_current_edition"]
for index in range(0, len(lines), 2)
}
self.assertEqual(flags, {"920292": True, "920492": False})
def test_switches_alias_atomically_after_successful_load(self):
with patch("search.minjust_opensearch.request_json", side_effect=[
{"akyldash-fragments-v1": {"aliases": {"akyldash-fragments-current": {}}}},
{"acknowledged": True},
]) as request:
switch_alias(
"http://127.0.0.1:9200/", "akyldash-fragments-v2",
"akyldash-fragments-current", "akyldash-fragments-v1",
)
self.assertEqual(request.call_args_list[1].args[:2], ("http://127.0.0.1:9200/_aliases", "POST"))
body = json.loads(request.call_args_list[1].args[2])
self.assertEqual(body["actions"][0], {"remove": {"index": "akyldash-fragments-v1", "alias": "akyldash-fragments-current", "must_exist": True}})
self.assertEqual(body["actions"][1], {"add": {"index": "akyldash-fragments-v2", "alias": "akyldash-fragments-current"}})
with self.assertRaisesRegex(ValueError, "differ"):
switch_alias("http://127.0.0.1:9200", "same", "same")
switch_alias("http://127.0.0.1:9200", "same", "same", "old")
with self.assertRaisesRegex(ValueError, "differ"):
switch_alias("http://127.0.0.1:9200", "index", "")
with patch("search.minjust_opensearch.request_json", return_value={"acknowledged": False}):
switch_alias("http://127.0.0.1:9200", "index", "", "old")
with patch("search.minjust_opensearch.request_json", return_value={
"akyldash-fragments-v3": {"aliases": {"alias": {}}}
}) as request:
with self.assertRaisesRegex(ValueError, "currently targets"):
switch_alias("http://127.0.0.1:9200", "new", "alias", "akyldash-fragments-v1")
request.assert_called_once()
with patch("search.minjust_opensearch.request_json", side_effect=[
{"old": {"aliases": {"alias": {}}}}, {"acknowledged": False},
{"old": {"aliases": {"alias": {}}}},
]):
with self.assertRaisesRegex(RuntimeError, "did not acknowledge"):
switch_alias("http://127.0.0.1:9200", "index", "alias")
switch_alias("http://127.0.0.1:9200", "index", "alias", "old")
def test_switch_alias_recovers_when_post_succeeds_but_response_is_lost(self):
with patch("search.minjust_opensearch.request_json", side_effect=[
{"old": {"aliases": {"alias": {}}}},
RuntimeError("response lost after OpenSearch applied request"),
{"candidate": {"aliases": {"alias": {}}}},
]) as request:
switch_alias("http://os:9200", "candidate", "alias", "old")
self.assertEqual(request.call_count, 3)
self.assertEqual(request.call_args_list[1].args[1], "POST")
self.assertEqual(request.call_args_list[2].args[1], "GET")
def test_switch_alias_is_idempotent_after_process_stops_before_checkpoint(self):
with patch("search.minjust_opensearch.request_json", return_value={
"candidate": {"aliases": {"alias": {}}}
}) as request:
switch_alias("http://os:9200", "candidate", "alias", "old")
request.assert_called_once()
def test_switch_alias_refuses_to_retry_after_unexpected_target(self):
with patch("search.minjust_opensearch.request_json", side_effect=[
{"old": {"aliases": {"alias": {}}}},
RuntimeError("response lost"),
{"other": {"aliases": {"alias": {}}}},
]):
with self.assertRaisesRegex(ValueError, "unexpected index"):
switch_alias("http://os:9200", "candidate", "alias", "old")
def test_resume_completes_checkpoint_when_alias_switched_before_previous_process_stopped(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
input_root = root / "normalized"
document_root = input_root / "documents/1"
document_root.mkdir(parents=True)
with closing(sqlite3.connect(input_root / "manifest.sqlite3")) as connection:
with connection:
connection.execute("CREATE TABLE documents (code TEXT, state TEXT)")
connection.execute("INSERT INTO documents VALUES ('1', 'success')")
(document_root / "document.json").write_text(
json.dumps({"schema_version": "1", "source_code": "1", "editions": [
{"source_code": "1", "source_order": 0},
]}),
encoding="utf-8",
)
text = "текст закона"
fragment = document_root / "editions/1/ru/fragments.json"
fragment.parent.mkdir(parents=True)
fragment.write_text(json.dumps([{
"id": "document:1:edition:1:lang:ru:fragment:1",
"document_code": "1", "edition_code": "1", "language": "ru", "position": 1,
"type": "paragraph", "text": text,
"text_sha256": hashlib.sha256(text.encode()).hexdigest(),
"source_path": "documents/1/editions/1/ru.html", "source_sha256": "b" * 64,
}]), encoding="utf-8")
checkpoint = root / "checkpoint.json"
state = {
"schema_version": 2,
"url": "http://os:9200",
"cluster_uuid": "cluster",
"index": "candidate",
"index_uuid": "index-uuid",
"input": str(input_root.resolve()),
"manifest_sha256": hashlib.sha256((input_root / "manifest.sqlite3").read_bytes()).hexdigest(),
"limit": None,
"alias": "current",
"last_document_code": "1",
"complete": False,
}
checkpoint.write_text(json.dumps(state), encoding="utf-8")
with (
patch("search.minjust_opensearch.request_json", side_effect=[
{"cluster_uuid": "cluster"},
{"candidate": {"settings": {"index": {"uuid": "index-uuid"}}}},
{"errors": False, "items": [{"index": {}}]},
{"candidate": {"aliases": {"current": {}}}},
]) as request,
patch("search.minjust_opensearch.validate_candidate_index") as validate_candidate,
):
self.assertEqual(
load_bulk(
input_root, "http://os:9200", "candidate", resume=True, checkpoint=checkpoint,
alias="current", expected_old_index="previous", canary_ru="закон", canary_ky="мыйзам",
),
(1, 1),
)
self.assertTrue(json.loads(checkpoint.read_text(encoding="utf-8"))["complete"])
self.assertEqual(request.call_args_list[-1].args[1], "GET")
validate_candidate.assert_called_once_with("http://os:9200", "candidate", 1, "закон", "мыйзам")
def test_zero_fragment_candidate_never_reaches_alias_switch(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary) / "normalized"
document = root / "documents/1"
document.mkdir(parents=True)
with closing(sqlite3.connect(root / "manifest.sqlite3")) as connection:
connection.execute("CREATE TABLE documents (code TEXT, state TEXT)")
connection.execute("INSERT INTO documents VALUES ('1', 'success')")
(document / "document.json").write_text(
json.dumps({"source_code": "1", "editions": []}), encoding="utf-8"
)
with (
patch("search.minjust_opensearch.request_json", side_effect=[
{}, {"cluster_uuid": "cluster"},
{"test-index": {"settings": {"index": {"uuid": "index"}}}},
]) as request,
patch("search.minjust_opensearch.validate_candidate_index") as validate_candidate,
patch("search.minjust_opensearch.switch_alias") as switch,
):
with self.assertRaisesRegex(ValueError, "empty expected fragment inventory"):
load_bulk(
root, "http://127.0.0.1:9200", "test-index",
checkpoint=Path(temporary) / "checkpoint.json", alias="current",
expected_old_index="old-index", canary_ru="закон", canary_ky="мыйзам",
)
self.assertEqual(request.call_count, 3)
validate_candidate.assert_not_called()
switch.assert_not_called()
def test_candidate_index_requires_exact_count_and_both_language_canaries(self):
from search.minjust_opensearch import validate_candidate_index
with patch("search.minjust_opensearch.request_json", side_effect=[
{"_shards": {"failed": 0}},
{"count": 2},
{"hits": {"hits": [{"_source": {"text_ru": "тест"}}]}},
{"hits": {"hits": [{"_source": {"text_ky": "сыноо"}}]}},
]) as request:
validate_candidate_index("http://os:9200", "candidate", 2, "тест", "сыноо")
self.assertEqual(request.call_count, 4)
with patch("search.minjust_opensearch.request_json", side_effect=[
{"_shards": {"failed": 0}}, {"count": 1}
]):
with self.assertRaisesRegex(RuntimeError, "expected 2"):
validate_candidate_index("http://os:9200", "candidate", 2, "тест", "сыноо")
with patch("search.minjust_opensearch.request_json", side_effect=[
{"_shards": {"failed": 0}}, {"count": 2},
{"hits": {"hits": [{"_source": {"text_ru": "тест"}}]}},
{"hits": {"hits": []}},
]):
with self.assertRaisesRegex(RuntimeError, "ky canary"):
validate_candidate_index("http://os:9200", "candidate", 2, "тест", "сыноо")
def test_exports_atomic_bulk_and_rejects_mismatched_fragment(self):
with tempfile.TemporaryDirectory() as temporary:
@@ -91,7 +304,9 @@ class MinjustOpenSearchTest(unittest.TestCase):
self.assertEqual(len(lines), 4)
self.assertEqual(lines[0]["index"]["_id"], "document:7:edition:10:lang:ky:fragment:1")
self.assertIn("text_ky", lines[1])
self.assertTrue(lines[1]["is_current_edition"])
self.assertNotIn("text_ru", lines[1])
self.assertEqual(lines[1]["legal_force_code"], "legislative")
self.assertEqual(lines[3]["text_ru"], "Текст \"RU\"\nстрока")
self.assertEqual(
list(bulk_batches(iter((("7", b"a\nb\n"), ("8", b"c\nd\n"))), 4)),
@@ -102,13 +317,16 @@ class MinjustOpenSearchTest(unittest.TestCase):
document_codes(root / "normalized", 1, "8")
checkpoint = root / "checkpoint.json"
with patch("search.minjust_opensearch.request_json") as request:
with (
patch("search.minjust_opensearch.request_json") as request,
patch("search.minjust_opensearch.validate_candidate_index") as validate_candidate,
patch("search.minjust_opensearch.switch_alias") as switch,
):
request.side_effect = [
{},
{"cluster_uuid": "cluster-1"},
{"test-index": {"settings": {"index": {"uuid": "index-1"}}}},
{"errors": False, "items": [{"index": {}}, {"index": {}}]},
{"acknowledged": True},
]
self.assertEqual(
load_bulk(
@@ -118,11 +336,19 @@ class MinjustOpenSearchTest(unittest.TestCase):
maximum_bytes=4096,
checkpoint=checkpoint,
alias="test-current",
expected_old_index="test-index-old",
canary_ru="закон",
canary_ky="мыйзам",
),
(2, 2),
)
self.assertEqual(request.call_args_list[-2].args[3], "application/x-ndjson")
self.assertEqual(request.call_args_list[-1].args[:2], ("http://127.0.0.1:9200/_aliases", "POST"))
self.assertEqual(request.call_args_list[-1].args[3], "application/x-ndjson")
validate_candidate.assert_called_once_with(
"http://127.0.0.1:9200", "test-index", 2, "закон", "мыйзам"
)
switch.assert_called_once_with(
"http://127.0.0.1:9200", "test-index", "test-current", "test-index-old"
)
state = json.loads(checkpoint.read_text(encoding="utf-8"))
self.assertEqual(state["last_document_code"], "7")
self.assertTrue(state["complete"])
@@ -133,7 +359,11 @@ class MinjustOpenSearchTest(unittest.TestCase):
legacy["last_document_code"] = "8"
legacy["complete"] = False
checkpoint.write_text(json.dumps(legacy), encoding="utf-8")
with patch("search.minjust_opensearch.request_json") as request:
with (
patch("search.minjust_opensearch.request_json") as request,
patch("search.minjust_opensearch.validate_candidate_index") as validate_candidate,
patch("search.minjust_opensearch.switch_alias"),
):
request.side_effect = [
{"cluster_uuid": "cluster-1"},
{"test-index": {"settings": {"index": {"uuid": "index-1"}}}},
@@ -168,6 +398,9 @@ class MinjustOpenSearchTest(unittest.TestCase):
resume=True,
checkpoint=checkpoint,
alias="test-current",
expected_old_index="test-index-old",
canary_ru="закон",
canary_ky="мыйзам",
)
with patch("search.minjust_opensearch.request_json") as request:
request.side_effect = [
@@ -198,12 +431,18 @@ class MinjustOpenSearchTest(unittest.TestCase):
resume=True,
checkpoint=checkpoint,
alias="test-current",
expected_old_index="test-index-old",
canary_ru="закон",
canary_ky="мыйзам",
)
with patch("search.minjust_opensearch.request_json") as request:
with (
patch("search.minjust_opensearch.request_json") as request,
patch("search.minjust_opensearch.validate_candidate_index") as validate_candidate,
patch("search.minjust_opensearch.switch_alias"),
):
request.side_effect = [
{"cluster_uuid": "cluster-1"},
{"test-index": {"settings": {"index": {"uuid": "index-1"}}}},
{"acknowledged": True},
]
self.assertEqual(
load_bulk(
@@ -214,11 +453,16 @@ class MinjustOpenSearchTest(unittest.TestCase):
resume=True,
checkpoint=checkpoint,
alias="test-current",
expected_old_index="test-index-old",
canary_ru="закон",
canary_ky="мыйзам",
),
(1, 0),
)
self.assertTrue(all(call.args[1] == "GET" for call in request.call_args_list[:-1]))
self.assertEqual(request.call_args_list[-1].args[1], "POST")
self.assertTrue(all(call.args[1] == "GET" for call in request.call_args_list))
validate_candidate.assert_called_once_with(
"http://127.0.0.1:9200", "test-index", 2, "закон", "мыйзам"
)
state["last_document_code"] = "9"
state["complete"] = False
@@ -237,6 +481,9 @@ class MinjustOpenSearchTest(unittest.TestCase):
resume=True,
checkpoint=checkpoint,
alias="test-current",
expected_old_index="test-index-old",
canary_ru="закон",
canary_ky="мыйзам",
)
failed_checkpoint = root / "failed-checkpoint.json"
@@ -283,6 +530,9 @@ class MinjustOpenSearchTest(unittest.TestCase):
resume=True,
checkpoint=checkpoint,
alias="test-current",
expected_old_index="test-index-old",
canary_ru="закон",
canary_ky="мыйзам",
)
http_error = urllib.error.HTTPError(
+234
View File
@@ -0,0 +1,234 @@
import json
import contextlib
import io
import tempfile
import unittest
import urllib.parse
from pathlib import Path
from unittest.mock import Mock, patch
from search.api import Api, ApiError, handler
class SearchApiTest(unittest.TestCase):
def make_api(self, root: Path) -> Api:
document = root / "documents/7"
edition = document / "editions/10"
edition.mkdir(parents=True)
(document / "document.json").write_text(json.dumps({
"source_code": "7", "available_languages": ["ru"],
"editions": [{"source_code": "10", "available_languages": ["ru"]},],
}), encoding="utf-8")
(edition / "edition.json").write_text(json.dumps({"source_code": "10", "available_languages": ["ru"]}), encoding="utf-8")
(edition / "ru").mkdir()
(edition / "ru/content.html").write_text("<p>Текст</p>", encoding="utf-8")
(edition / "ru/content.txt").write_text("Текст\n", encoding="utf-8")
return Api("http://opensearch:9200", "current", root)
def test_static_font_assets_are_allowlisted(self):
content_type, body = Api.static_asset("/static/fonts/noto-sans-cyrillic-ext.woff2")
self.assertEqual(content_type, "font/woff2")
self.assertTrue(body.startswith(b"wOF2"))
content_type, body = Api.static_asset("/static/favicon.svg")
self.assertEqual(content_type, "image/svg+xml")
self.assertIn(b"<svg", body)
content_type, body = Api.static_asset("/static/favicon-32.png")
self.assertEqual(content_type, "image/png")
self.assertTrue(body.startswith(b"\x89PNG\r\n\x1a\n"))
with self.assertRaisesRegex(ApiError, "static asset not found"):
Api.static_asset("/static/fonts/../../DESIGN.md")
def test_users_screen_is_served_without_exposing_admin_api(self):
with tempfile.TemporaryDirectory() as temporary:
api = self.make_api(Path(temporary))
request_handler = handler(api)
def get(path):
request = request_handler.__new__(request_handler)
request.path = path
request.headers = {}
request.wfile = io.BytesIO()
request.send_response = Mock()
request.send_header = Mock()
request.end_headers = Mock()
request.do_GET()
return request
users_page = get("/users")
users_page.send_response.assert_called_once_with(200)
self.assertIn(b'id="users-page"', users_page.wfile.getvalue())
admin_api = get("/admin/users")
admin_api.send_response.assert_called_once_with(401)
def test_account_capability_matches_the_admin_users_api_guard(self):
with tempfile.TemporaryDirectory() as temporary:
api = self.make_api(Path(temporary))
api.auth_store.create_admin("Fixture-only-admin-password-2026")
admin_token, _ = api.auth_store.login("admin", "Fixture-only-admin-password-2026")
api.auth_store.create_user("fixture-user", "Fixture-only-temporary-password-2026")
api.auth_store.change_password("fixture-user", "Fixture-only-temporary-password-2026", "Fixture-only-user-password-2026")
user_token, _ = api.auth_store.login("fixture-user", "Fixture-only-user-password-2026")
request_handler = handler(api)
def get(path, token):
request = request_handler.__new__(request_handler)
request.path = path
request.headers = {"Cookie": f"akyldash_session={token}"}
request.wfile = io.BytesIO()
request.send_response = Mock()
request.send_header = Mock()
request.end_headers = Mock()
request.do_GET()
return request
admin_account = json.loads(get("/auth/me", admin_token).wfile.getvalue())["account"]
user_account = json.loads(get("/auth/me", user_token).wfile.getvalue())["account"]
self.assertEqual((admin_account["role"], admin_account["can_manage_users"]), ("Администратор", True))
self.assertEqual((user_account["role"], user_account["can_manage_users"]), ("Пользователь", False))
get("/admin/users", admin_token).send_response.assert_called_once_with(200)
get("/admin/users", user_token).send_response.assert_called_once_with(403)
def test_search_pagination_filters_and_highlight(self):
with tempfile.TemporaryDirectory() as temporary:
api = self.make_api(Path(temporary))
response = {"hits": {"hits": [{"_source": {"document_code": "7", "edition_code": "10", "document_name_ru": "Закон"}, "highlight": {"text_ru": ["<em>Закон</em>"]}}]}}
with patch("search.api.request_json", return_value=response) as request:
status, payload = api.handle("GET", "/search?q=%D0%B7%D0%B0%D0%BA%D0%BE%D0%BD&language=ru&page=2&page_size=5&status=active")
self.assertEqual(status, 200)
self.assertEqual(payload["results"][0]["snippet"], "Закон")
body = json.loads(request.call_args.args[2])
self.assertEqual((body["from"], body["size"]), (5, 6))
self.assertIn({"term": {"status_code": "active"}}, body["query"]["bool"]["filter"])
with self.assertRaisesRegex(ApiError, "within 10000 results"):
api.handle("GET", "/search?q=x&page=100&page_size=100")
def test_review_secret_file_is_read_without_command_line_value(self):
from search.api import main
with tempfile.TemporaryDirectory() as temporary:
secret_file = Path(temporary) / "secret"
secret_file.write_text("secret-value\n", encoding="utf-8")
with patch("sys.argv", ["search.api", "--review-secret-file", str(secret_file)]), patch("search.api.Api") as api, patch("search.api.ThreadingHTTPServer"):
main()
self.assertEqual(api.call_args.args[4], b"secret-value")
def test_review_secret_file_rejects_empty_content(self):
from search.api import main
with tempfile.TemporaryDirectory() as temporary:
secret_file = Path(temporary) / "secret"
secret_file.touch()
with patch("sys.argv", ["search.api", "--review-secret-file", str(secret_file)]), contextlib.redirect_stderr(io.StringIO()):
with self.assertRaises(SystemExit) as result:
main()
self.assertEqual(result.exception.code, 2)
def test_production_api_uses_registration_intent_boosts(self):
with tempfile.TemporaryDirectory() as temporary:
api = self.make_api(Path(temporary))
response = {"hits": {"hits": []}}
with patch("search.api.request_json", return_value=response) as request:
api.handle("GET", "/search?q=%D0%BA%D0%B0%D0%BA+%D0%BE%D1%82%D0%BA%D1%80%D1%8B%D1%82%D1%8C+%D0%9E%D1%81%D0%9E%D0%9E&language=ru")
body = json.loads(request.call_args.args[2])
planned = body["query"]["bool"]["must"]["bool"]
self.assertEqual(planned["minimum_should_match"], 1)
self.assertNotIn("filter", planned)
self.assertTrue(any("constant_score" in clause for clause in planned["should"]))
self.assertTrue(any("общество с ограниченной ответственностью" in clause.get("multi_match", {}).get("query", "") for clause in planned["should"]))
def test_production_api_boosts_tax_code_for_profit_tax_query(self):
with tempfile.TemporaryDirectory() as temporary:
api = self.make_api(Path(temporary))
with patch("search.api.request_json", return_value={"hits": {"hits": []}}) as request:
api.handle("GET", "/search?q=%D0%BD%D0%B0%D0%BB%D0%BE%D0%B3+%D0%BD%D0%B0+%D0%BF%D1%80%D0%B8%D0%B1%D1%8B%D0%BB%D1%8C&language=ru")
body = json.loads(request.call_args.args[2])
planned = body["query"]["bool"]["must"]["bool"]
anchors = [clause["constant_score"]["filter"]["bool"]["filter"][0] for clause in planned["should"] if "constant_score" in clause]
self.assertIn({"term": {"document_code": "112340"}}, anchors)
def test_search_sorts_repealed_documents_after_other_statuses(self):
with tempfile.TemporaryDirectory() as temporary:
api = self.make_api(Path(temporary))
with patch("search.api.request_json", return_value={"hits": {"hits": []}}) as request:
api.handle("GET", "/search?q=НДС+на+импорт&language=ru")
body = json.loads(request.call_args.args[2])
source = body["sort"][0]["_script"]["script"]["source"]
self.assertIn("'active' ? 0", source)
self.assertIn("'repealed' ? 2", source)
self.assertEqual(body["sort"][1], {"_score": "desc"})
api.handle("GET", "/search?q=НДС+на+импорт&language=ru&sort=date")
body = json.loads(request.call_args.args[2])
self.assertEqual(body["sort"][1], {"date_adopted": "desc"})
def test_document_editions_openapi_and_validation(self):
with tempfile.TemporaryDirectory() as temporary:
api = self.make_api(Path(temporary))
specification = api.handle("GET", "/openapi.json")[1]
self.assertEqual(specification["info"]["version"], "v1")
self.assertEqual(specification["paths"]["/documents/{code}"]["get"]["parameters"][0]["required"], True)
self.assertEqual(specification["paths"]["/documents/{code}/editions/{edition}"]["get"]["parameters"][1]["name"], "edition")
self.assertEqual(api.handle("GET", "/documents/7")[1]["current_edition"]["source_code"], "10")
self.assertEqual(api.handle("GET", "/documents/7/editions/10?language=ru")[1]["content"]["ru"]["text"], "Текст\n")
with self.assertRaisesRegex(ApiError, "q is required"):
api.handle("GET", "/search")
with self.assertRaisesRegex(ApiError, "date_from must be an ISO date"):
api.handle("GET", "/search?q=x&date_from=tomorrow")
with self.assertRaisesRegex(ApiError, "document not found"):
api.handle("GET", "/documents/%2E%2E")
def test_filters_count_documents_and_return_bilingual_labels(self):
with tempfile.TemporaryDirectory() as temporary:
api = self.make_api(Path(temporary))
keys = {"document_types": "law", "statuses": "active", "authorities": "parliament", "legal_forces": "legislative"}
response = {"aggregations": {name: {"buckets": [{"key": keys[name], "documents": {"value": 3}}]} for name in keys}}
with patch("search.api.request_json", return_value=response) as request:
payload = api.handle("GET", "/search/filters?language=ky")[1]
self.assertEqual(payload["document_types"][0], {"code": "law", "labels": {"ru": "Закон", "ky": "Мыйзам"}, "count": 3})
self.assertEqual(payload["legal_forces"][0]["labels"]["ky"], "Мыйзам деңгээли")
body = json.loads(request.call_args.args[2])
self.assertIn("terms", body["aggs"]["document_types"])
self.assertIn("legal_force_code", body["aggs"]["legal_forces"]["terms"]["field"])
self.assertEqual(body["query"], {"term": {"is_current_edition": True}})
def test_global_dfs_is_only_sent_for_explicit_multi_index_searches(self):
with tempfile.TemporaryDirectory() as temporary:
multi = Api("http://opensearch:9200", "base-v2,supplement-v1", Path(temporary), review_secret=b"test-secret")
alias = Api("http://opensearch:9200", "current", Path(temporary), review_secret=b"test-secret")
single = Api("http://opensearch:9200", "single-v2", Path(temporary), review_secret=b"test-secret")
one_hit = {"hits": {"hits": [{"_index": "base-v2", "_source": {"document_code": "5", "document_name_ru": "Кодекс", "document_name_ky": "Кодекс"}}]}}
empty = {"hits": {"hits": []}}
alias_hit = {"hits": {"hits": [{"_index": "current-20261103", "_source": {"document_code": "7", "document_name_ru": "Закон"}}]}}
aggregations = {"aggregations": {name: {"buckets": []} for name in ("document_types", "statuses", "authorities", "legal_forces")}}
with patch("search.api.request_json", side_effect=[one_hit, empty, alias_hit, one_hit, aggregations]) as request:
multi_ru = multi.search({"q": ["test"], "language": ["ru"], "page_size": ["10"]})
multi_ky_empty = multi.search({"q": ["test"], "language": ["ky"], "page_size": ["10"]})
alias_ru = alias.search({"q": ["test"], "language": ["ru"], "page_size": ["10"]})
single_ky = single.search({"q": ["test"], "language": ["ky"], "page_size": ["10"]})
alias.filters({"language": ["ru"]})
calls = request.call_args_list
multi_ru_url = urllib.parse.urlsplit(calls[0].args[0])
multi_ky_url = urllib.parse.urlsplit(calls[1].args[0])
self.assertEqual(multi_ru_url.path, "/base-v2%2Csupplement-v1/_search")
self.assertEqual(urllib.parse.parse_qs(multi_ru_url.query), {"search_type": ["dfs_query_then_fetch"]})
self.assertEqual(urllib.parse.parse_qs(multi_ky_url.query), {"search_type": ["dfs_query_then_fetch"]})
self.assertEqual([item["code"] for item in multi_ru["results"]], ["5"])
self.assertEqual(multi_ky_empty["results"], [])
self.assertEqual(multi.review_snapshots.verify(multi_ru["review_token"])["index_name"], "base-v2,supplement-v1")
self.assertEqual(multi.review_snapshots.verify(multi_ky_empty["review_token"])["index_name"], "base-v2,supplement-v1")
for call in calls[2:]:
parsed = urllib.parse.urlsplit(call.args[0])
self.assertEqual(parsed.query, "")
self.assertEqual(alias.review_snapshots.verify(alias_ru["review_token"])["index_name"], "current-20261103")
self.assertEqual(single.review_snapshots.verify(single_ky["review_token"])["index_name"], "base-v2")
multi_body = json.loads(calls[0].args[2])
alias_body = json.loads(calls[2].args[2])
self.assertEqual(multi_body, alias_body)
self.assertNotIn("search_type", multi_body)
self.assertEqual(json.loads(calls[1].args[2])["query"]["bool"]["filter"][0], {"term": {"language": "ky"}})
if __name__ == "__main__":
unittest.main()
+65
View File
@@ -0,0 +1,65 @@
import json
import os
import unittest
import uuid
from pathlib import Path
from search.api import Api
from search.minjust_opensearch import DEFAULT_MAPPING, request_json
@unittest.skipUnless(os.getenv("AKYLDASH_OPENSEARCH_URL"), "set AKYLDASH_OPENSEARCH_URL to run against local OpenSearch")
class SearchApiOpenSearchTest(unittest.TestCase):
def test_current_editions_filters_and_catalogs(self):
base_url = os.environ["AKYLDASH_OPENSEARCH_URL"].rstrip("/")
index = f"akyldash-api-test-{uuid.uuid4().hex}"
request_json(f"{base_url}/{index}", "PUT", DEFAULT_MAPPING.read_bytes(), "application/json")
try:
documents = [
{"document_code": "1", "edition_code": "1", "is_current_edition": False, "language": "ru", "position": 1, "fragment_type": "paragraph", "text_ru": "historic", "document_name_ru": "Old law", "document_type_ru": "Закон", "document_type_ky": "Мыйзам", "document_type_code": "law", "legal_force_code": "legislative", "status_ru": "Утратил силу", "status_ky": "Күчүн жоготту", "status_code": "repealed", "date_adopted": "2020-01-01", "authority_paths_ru": ["Парламент"], "authority_paths_ky": ["Парламент"], "authority_codes": ["parliament"]},
{"document_code": "1", "edition_code": "2", "is_current_edition": True, "language": "ru", "position": 1, "fragment_type": "paragraph", "text_ru": "needle", "document_name_ru": "Current law", "document_type_ru": "Закон", "document_type_ky": "Мыйзам", "document_type_code": "law", "legal_force_code": "legislative", "status_ru": "Действует", "status_ky": "Күчүндө", "status_code": "active", "date_adopted": "2021-01-01", "authority_paths_ru": ["Парламент"], "authority_paths_ky": ["Парламент"], "authority_codes": ["parliament"]},
{"document_code": "2", "edition_code": "1", "is_current_edition": True, "language": "ru", "position": 1, "fragment_type": "paragraph", "text_ru": "needle", "document_name_ru": "Current decree", "document_type_ru": "Указ", "document_type_ky": "Жарлык", "document_type_code": "decree", "legal_force_code": "subordinate", "status_ru": "Действует", "status_ky": "Күчүндө", "status_code": "active", "date_adopted": "2022-01-01", "authority_paths_ru": ["Президент"], "authority_paths_ky": ["Президент"], "authority_codes": ["president"]},
]
for number, document in enumerate(documents):
request_json(f"{base_url}/{index}/_doc/{number}", "PUT", json.dumps(document).encode(), "application/json")
request_json(f"{base_url}/{index}/_refresh", "POST", None, "application/json")
api = Api(base_url, index, Path("."))
self.assertEqual(api.handle("GET", "/search?q=historic")[1]["results"], [])
filtered = api.handle("GET", "/search?q=needle&document_type=law")[1]
self.assertEqual([result["code"] for result in filtered["results"]], ["1"])
force_filtered = api.handle("GET", "/search?q=needle&legal_force=subordinate")[1]
self.assertEqual([result["code"] for result in force_filtered["results"]], ["2"])
self.assertEqual(force_filtered["results"][0]["legal_force"], "Подзаконный уровень")
filters = api.handle("GET", "/search/filters")[1]
self.assertEqual({item["count"] for item in filters["statuses"]}, {2})
self.assertEqual({item["code"] for item in filters["legal_forces"]}, {"legislative", "subordinate"})
finally:
request_json(f"{base_url}/{index}", "DELETE", None, "application/json")
@unittest.skipUnless(os.getenv("AKYLDASH_OPENSEARCH_URL"), "set AKYLDASH_OPENSEARCH_URL to run against local OpenSearch")
class SearchRankingOpenSearchTest(unittest.TestCase):
def test_generic_lease_query_ranks_general_civil_code_first(self):
base_url = os.environ["AKYLDASH_OPENSEARCH_URL"].rstrip("/")
index = os.getenv("AKYLDASH_OPENSEARCH_INDEX", "akyldash-fragments-v2")
api = Api(base_url, index, Path("."))
result = api.search({"q": ["расторжение договора аренды"], "language": ["ru"], "page_size": ["10"]})
self.assertEqual(result["results"][0]["code"], "5")
@unittest.skipUnless(
os.getenv("AKYLDASH_OPENSEARCH_URL") and "," in os.getenv("AKYLDASH_OPENSEARCH_INDEX", ""),
"set AKYLDASH_OPENSEARCH_URL and a comma-separated AKYLDASH_OPENSEARCH_INDEX for read-only multi-index search",
)
class SearchMultiIndexDfsOpenSearchTest(unittest.TestCase):
def test_global_statistics_mode_accepts_read_only_multi_index_search(self):
base_url = os.environ["AKYLDASH_OPENSEARCH_URL"].rstrip("/")
index = os.environ["AKYLDASH_OPENSEARCH_INDEX"]
api = Api(base_url, index, Path("."))
result = api.search({"q": ["расторжение договора аренды"], "language": ["ru"], "page_size": ["10"]})
self.assertIsInstance(result["results"], list)
self.assertEqual(api.review_snapshots.verify(result["review_token"])["index_name"], index)
if __name__ == "__main__":
unittest.main()
+133 -7
View File
@@ -5,7 +5,7 @@ from pathlib import Path
from unittest.mock import patch
from search.evaluate_relevance import evaluate, load_queries
from search.query import build_search_body
from search.query import COMPANY_REGISTRATION_DOCUMENTS, LEASE_TERM_PHRASE_BOOST, TAX_PROFIT_DOCUMENTS, build_search_body, query_variants
class SearchRelevanceTest(unittest.TestCase):
@@ -37,7 +37,113 @@ class SearchRelevanceTest(unittest.TestCase):
body = json.loads(build_search_body("ru", "трудовой договор", 10))
self.assertFalse(body["track_total_hits"])
self.assertEqual(body["collapse"], {"field": "document_code"})
self.assertEqual(body["query"]["bool"]["must"]["multi_match"]["type"], "cross_fields")
self.assertIn({"term": {"is_current_edition": True}}, body["query"]["bool"]["filter"])
lexical_query = body["query"]["bool"]["must"]["bool"]["should"][0]["multi_match"]
self.assertEqual(lexical_query["type"], "cross_fields")
self.assertEqual(lexical_query["fields"], ["document_name_ru^4", "text_ru"])
self.assertEqual(lexical_query["operator"], "AND")
self.assertTrue(any("match_phrase" in clause for clause in body["query"]["bool"]["must"]["bool"]["should"]))
def test_legal_form_abbreviations_expand_in_both_directions(self):
self.assertIn("как открыть общество с ограниченной ответственностью", query_variants("ru", "как открыть ОсОО"))
self.assertIn("регистрация осоо", [variant.casefold() for variant in query_variants("ru", "регистрация общества с ограниченной ответственностью")])
self.assertIn("жчк ачуу тартиби", [variant.casefold() for variant in query_variants("ky", "жоопкерчилиги чектелген коом ачуу тартиби")])
def test_russian_lease_phrase_expands_only_the_exact_legal_construction(self):
original = "расторжение договора аренды"
variants = query_variants("ru", original)
self.assertEqual(variants[0], original)
self.assertIn("расторжение договора имущественного найма", variants)
body = json.loads(build_search_body("ru", original, 10))
clauses = body["query"]["bool"]["must"]["bool"]["should"]
full_text = [item["multi_match"]["query"] for item in clauses if "multi_match" in item and item["multi_match"].get("operator") == "AND"]
self.assertEqual(full_text, variants)
phrase_matches = [item["match_phrase"]["text_ru"] for item in clauses if "match_phrase" in item]
self.assertEqual([item["boost"] for item in phrase_matches], [3, LEASE_TERM_PHRASE_BOOST])
for query in (
"аренда квартиры",
"расторжение договора аренды квартиры",
"договор аренды земельного участка",
"договор поднайма",
"договор аренды с правом поднайма",
"наем работника",
"договор лизинга",
):
self.assertEqual(query_variants("ru", query), [query])
self.assertEqual(query_variants("ky", "договор аренды"), ["договор аренды"])
self.assertEqual(
query_variants("ru", "договору аренды"),
["договору аренды", "договору имущественного найма"],
)
def test_relaxed_match_drops_ru_template_prefix_and_stays_low_weight(self):
body = json.loads(build_search_body("ru", "правовое регулирование расторжение договора аренды", 10))
clauses = body["query"]["bool"]["must"]["bool"]["should"]
strict = [item["multi_match"] for item in clauses if "multi_match" in item and item["multi_match"].get("operator") == "AND"]
relaxed = [item["multi_match"] for item in clauses if "multi_match" in item and item["multi_match"].get("boost") == 0.1]
self.assertEqual(strict[0]["query"], "правовое регулирование расторжение договора аренды")
self.assertEqual(relaxed[0]["query"], "расторжение договора аренды")
self.assertEqual(relaxed[0]["operator"], "OR")
self.assertEqual(relaxed[0]["minimum_should_match"], 2)
self.assertNotIn("boost", strict[0])
def test_relaxed_match_supports_kyrgyz_and_avoids_short_named_acts(self):
ky = json.loads(build_search_body("ky", "эмгек келишимин бузуу шарттары", 10))
ky_clauses = ky["query"]["bool"]["must"]["bool"]["should"]
self.assertTrue(any(item.get("multi_match", {}).get("operator") == "OR" and item["multi_match"].get("boost") == 0.1 for item in ky_clauses))
named_act = json.loads(build_search_body("ru", "Гражданский кодекс", 10))
act_clauses = named_act["query"]["bool"]["must"]["bool"]["should"]
self.assertFalse(any(item.get("multi_match", {}).get("boost") == 0.1 for item in act_clauses))
long_named_act = json.loads(build_search_body("ru", "последние изменения Гражданского кодекса Кыргызской Республики", 10))
long_act_clauses = long_named_act["query"]["bool"]["must"]["bool"]["should"]
self.assertFalse(any(item.get("multi_match", {}).get("boost") == 0.1 for item in long_act_clauses))
short_template = json.loads(build_search_body("ru", "как регулируется взыскание алиментов", 10))
short_clauses = short_template["query"]["bool"]["must"]["bool"]["should"]
short_fallback = next(item["multi_match"] for item in short_clauses if item.get("multi_match", {}).get("boost") == 0.1)
self.assertEqual(short_fallback["query"], "взыскание алиментов")
self.assertEqual(short_fallback["minimum_should_match"], 2)
stopword_query = json.loads(build_search_body("ru", "правовое регулирование права и обязанности", 10))
stopword_clauses = stopword_query["query"]["bool"]["must"]["bool"]["should"]
stopword_fallback = next(item["multi_match"] for item in stopword_clauses if item.get("multi_match", {}).get("boost") == 0.1)
self.assertEqual(stopword_fallback["minimum_should_match"], 2)
for language, query in (("ru", "последние изменения ГК КР"), ("ky", "Кыргыз Республикасынын Конституциясы")):
act_body = json.loads(build_search_body(language, query, 10))
act_clauses = act_body["query"]["bool"]["must"]["bool"]["should"]
self.assertFalse(any(item.get("multi_match", {}).get("boost") == 0.1 for item in act_clauses))
for language, query, expected in (
("ru", "Налоговый кодекс ответственность за неуплату налога", "ответственность за неуплату налога"),
("ru", "Налоговый кодекс налоговая ставка", "налоговая ставка"),
("ru", "Налоговый кодекс: налоговая ставка", "налоговая ставка"),
("ru", "закон о труде порядок выплаты зарплаты", "порядок выплаты зарплаты"),
("ky", "Кыргыз Республикасынын Конституциясы укуктарды чектөө тартиби", "укуктарды чектөө тартиби"),
):
body = json.loads(build_search_body(language, query, 10))
clauses = body["query"]["bool"]["must"]["bool"]["should"]
fallback = next(item["multi_match"] for item in clauses if item.get("multi_match", {}).get("boost") == 0.1)
self.assertEqual(fallback["query"], expected)
for query in (
"Закон о защите прав потребителей",
"Закон Кыргызской Республики о защите прав потребителей",
"Закон Кыргызской Республики о государственной регистрации юридических лиц",
):
body = json.loads(build_search_body("ru", query, 10))
clauses = body["query"]["bool"]["must"]["bool"]["should"]
self.assertFalse(any(item.get("multi_match", {}).get("boost") == 0.1 for item in clauses))
consumer_rights = json.loads(build_search_body("ru", "Закон о защите прав потребителей возврат денег", 10))
consumer_clauses = consumer_rights["query"]["bool"]["must"]["bool"]["should"]
consumer_fallback = next(item["multi_match"] for item in consumer_clauses if item.get("multi_match", {}).get("boost") == 0.1)
self.assertEqual(consumer_fallback["query"], "возврат денег")
def test_company_registration_intent_boosts_current_documents(self):
for language, query, status in (
@@ -45,21 +151,40 @@ class SearchRelevanceTest(unittest.TestCase):
("ky", "ЖЧК ачуу тартиби", "Күчүндө"),
):
body = json.loads(build_search_body(language, query, 10))
search_query = body["query"]["bool"]
search_query = body["query"]["bool"]["must"]["bool"]
self.assertEqual(search_query["minimum_should_match"], 1)
boosts = [clause["constant_score"] for clause in search_query["should"][1:]]
self.assertEqual([item["boost"] for item in boosts], [2000, 1000])
self.assertNotIn("filter", search_query)
lexical = [clause["multi_match"] for clause in search_query["should"] if "multi_match" in clause]
self.assertTrue(all(clause["type"] == "cross_fields" and clause["operator"] == "OR" for clause in lexical))
boosts = [clause["constant_score"] for clause in search_query["should"] if "constant_score" in clause]
self.assertEqual([item["boost"] for item in boosts], [boost for _, boost in COMPANY_REGISTRATION_DOCUMENTS])
self.assertEqual(
[item["filter"]["bool"]["filter"][0]["term"]["document_code"] for item in boosts],
["230044970", "667"],
[code for code, _ in COMPANY_REGISTRATION_DOCUMENTS],
)
self.assertTrue(all(item["filter"]["bool"]["filter"][1] == {"term": {f"status_{language}": status}} for item in boosts))
def test_profit_tax_intent_prioritizes_the_current_tax_code(self):
for query in ("налог на прибыль", "налога на прибыль организаций", "налогообложение прибыли"):
body = json.loads(build_search_body("ru", query, 10))
clauses = body["query"]["bool"]["must"]["bool"]["should"]
boosts = [clause["constant_score"] for clause in clauses if "constant_score" in clause]
self.assertEqual([item["boost"] for item in boosts], [boost for _, boost in TAX_PROFIT_DOCUMENTS])
self.assertEqual([item["filter"]["bool"]["filter"][0]["term"]["document_code"] for item in boosts], ["112340"])
self.assertTrue(all(item["filter"]["bool"]["filter"][1] == {"term": {"status_ru": "Действует"}} for item in boosts))
for query in ("прибыль организации", "налог на доходы", "налог на имущество"):
body = json.loads(build_search_body("ru", query, 10))
clauses = body["query"]["bool"]["must"]["bool"]["should"]
self.assertFalse(any("constant_score" in clause for clause in clauses))
def test_company_registration_intent_ignores_non_procedural_queries(self):
for language, query in (
("ru", "ОсОО зарегистрирован?"),
("ru", "кто зарегистрировал ОсОО"),
("ru", "как открыть счет ОсОО"),
("ru", "как открыть ОсОО банковский счет"),
("ru", "как открыть ОсОО банковский счёт"),
("ru", "как открыть филиал ОсОО"),
("ru", "как создать договор для ОсОО"),
("ru", "порядок создания логотипа ОсОО"),
@@ -69,7 +194,8 @@ class SearchRelevanceTest(unittest.TestCase):
("ky", "ЖЧК кантип логотип түзүү"),
):
body = json.loads(build_search_body(language, query, 10))
self.assertNotIn("should", body["query"]["bool"])
clauses = body["query"]["bool"]["must"]["bool"]["should"]
self.assertFalse(any("constant_score" in clause for clause in clauses))
if __name__ == "__main__":
+174
View File
@@ -0,0 +1,174 @@
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from search.api import Api, ApiError, SEARCH_ALGORITHM_VERSION
class SearchReviewTest(unittest.TestCase):
def test_multi_index_snapshot_and_saved_review_preserve_configured_scope(self):
with tempfile.TemporaryDirectory() as temporary:
api = Api("http://opensearch:9200", "A,B", Path(temporary), Path(temporary) / "reviews.sqlite3", b"test-secret")
cases = (
("no hits", []),
("A only", [{"_index": "A", "_source": {"document_code": "7", "document_name_ru": "Закон A"}}]),
("B only", [{"_index": "B", "_source": {"document_code": "8", "document_name_ru": "Закон B"}}]),
("mixed", [
{"_index": "A", "_source": {"document_code": "7", "document_name_ru": "Закон A"}},
{"_index": "B", "_source": {"document_code": "8", "document_name_ru": "Закон B"}},
]),
)
for label, hits in cases:
with self.subTest(case=label):
with patch("search.api.request_json", return_value={"hits": {"hits": hits}}):
result = api.handle("GET", "/search?q=test&language=ru")[1]
token = result["review_token"]
self.assertEqual(api.review_snapshots.verify(token)["index_name"], "A,B")
review_results = [
{"rank": rank, "code": item["code"], "rating": 3}
for rank, item in enumerate(result["results"], 1)
]
api.handle("POST", "/search-reviews", {
"review_token": token,
"reviewer": "Юрист",
"results": review_results,
"overall_comment": "Проверена выдача для настроенного scope.",
})
saved = api.handle("GET", "/search-reviews/export")[1]["reviews"][-1]
self.assertEqual(saved["index_name"], "A,B")
def test_review_must_cover_each_snapshot_rank_once(self):
with tempfile.TemporaryDirectory() as temporary:
api = Api("http://opensearch:9200", "current", Path(temporary), Path(temporary) / "reviews.sqlite3", b"test-secret")
response = {"hits": {"hits": [{"_index": "search-20260827", "_source": {"document_code": "7", "edition_code": "10", "document_name_ru": "Закон"}}, {"_index": "search-20260827", "_source": {"document_code": "8", "edition_code": "11", "document_name_ru": "Кодекс"}}]}}
with patch("search.api.request_json", return_value=response):
result = api.handle("GET", "/search?q=test&language=ru&page_size=2")[1]
with self.assertRaisesRegex(ApiError, "exactly once"):
api.handle("POST", "/search-reviews", {"review_token": result["review_token"], "reviewer": "Юрист", "results": []})
with self.assertRaisesRegex(ApiError, "exactly once"):
api.handle("POST", "/search-reviews", {"review_token": result["review_token"], "reviewer": "Юрист", "results": [{"rank": 1, "code": "7", "rating": 3}]})
def test_invalid_rank_types_and_sets_are_rejected_as_bad_requests(self):
with tempfile.TemporaryDirectory() as temporary:
api = Api("http://opensearch:9200", "current", Path(temporary), review_secret=b"test-secret")
response = {"hits": {"hits": [
{"_source": {"document_code": "7", "edition_code": "10", "document_name_ru": "Закон"}},
{"_source": {"document_code": "8", "edition_code": "11", "document_name_ru": "Кодекс"}},
]}}
with patch("search.api.request_json", return_value=response):
result = api.handle("GET", "/search?q=test&language=ru&page_size=2")[1]
token = result["review_token"]
malformed = [
{"rank": True, "code": "7", "rating": 3},
{"rank": [1], "code": "7", "rating": 3},
{"rank": {"value": 1}, "code": "7", "rating": 3},
{"rank": None, "code": "7", "rating": 3},
{"rank": "1", "code": "7", "rating": 3},
{"rank": 1.0, "code": "7", "rating": 3},
None,
]
for item in malformed:
with self.subTest(item=item):
with self.assertRaises(ApiError) as raised:
api.handle("POST", "/search-reviews", {"review_token": token, "reviewer": "Юрист", "results": [item]})
self.assertEqual(raised.exception.status, 400)
for submitted in (
[],
[{"rank": 1, "code": "7", "rating": 3}, {"rank": 1, "code": "7", "rating": 3}],
[{"rank": 1, "code": "7", "rating": 3}, {"rank": 3, "code": "8", "rating": 3}],
[{"rank": 1, "code": "7", "rating": 3}],
{"rank": 1, "code": "7", "rating": 3},
):
with self.subTest(submitted=submitted):
with self.assertRaises(ApiError) as raised:
api.handle("POST", "/search-reviews", {"review_token": token, "reviewer": "Юрист", "results": submitted})
self.assertEqual(raised.exception.status, 400)
oversized = [{"rank": 1, "code": "7", "rating": 3}] * 101
with self.assertRaises(ApiError) as raised:
api.handle("POST", "/search-reviews", {"review_token": token, "reviewer": "Юрист", "results": oversized})
self.assertEqual(raised.exception.status, 400)
def test_saves_signed_search_snapshot_and_rejects_tampering(self):
with tempfile.TemporaryDirectory() as temporary:
api = Api("http://opensearch:9200", "current", Path(temporary), Path(temporary) / "reviews.sqlite3", b"test-secret")
response = {"hits": {"hits": [{"_index": "search-20260827", "_source": {"document_code": "7", "edition_code": "10", "document_name_ru": "Закон"}}]}}
with patch("search.api.request_json", return_value=response):
result = api.handle("GET", "/search?q=%D0%B7%D0%B0%D0%BA%D0%BE%D0%BD&language=ru")[1]
self.assertEqual(api.review_snapshots.verify(result["review_token"])["index_name"], "search-20260827")
saved = api.handle("POST", "/search-reviews", {"review_token": result["review_token"], "reviewer": "Юрист", "results": [{"rank": 1, "code": "7", "rating": 3, "comment": "Прямой ответ"}]})
self.assertEqual(saved[0], 201)
exported = api.handle("GET", "/search-reviews/export")[1]["reviews"]
self.assertEqual(exported[0]["index_name"], "search-20260827")
self.assertEqual(exported[0]["top_result_code"], "7")
self.assertEqual(exported[0]["results"][0]["rating"], 3)
with self.assertRaisesRegex(ApiError, "does not match"):
api.handle("POST", "/search-reviews", {"review_token": result["review_token"], "reviewer": "Юрист", "results": [{"rank": 1, "code": "8", "rating": 3}]})
def test_saves_signed_empty_snapshot_with_query_level_comment(self):
with tempfile.TemporaryDirectory() as temporary:
api = Api("http://opensearch:9200", "current", Path(temporary), Path(temporary) / "reviews.sqlite3", b"test-secret")
with patch("search.api.request_json", return_value={"hits": {"hits": []}}):
result = api.handle("GET", "/search?q=редкий+запрос&language=ru")[1]
self.assertEqual(api.review_snapshots.verify(result["review_token"])["index_name"], "current")
body = {
"review_token": result["review_token"],
"reviewer": "Юрист",
"overall_comment": "Пустая выдача подтверждена.",
"results": [],
}
with self.assertRaisesRegex(ApiError, "overall_comment is required"):
api.handle("POST", "/search-reviews", {**body, "overall_comment": ""})
saved = api.handle("POST", "/search-reviews", body)
self.assertEqual(saved[0], 201)
review = api.handle("GET", "/search-reviews/export")[1]["reviews"][0]
self.assertIsNone(review["top_result_code"])
self.assertEqual(review["results"], [])
self.assertEqual(review["overall_comment"], body["overall_comment"])
def test_review_page_enables_query_level_review_for_empty_results(self):
with tempfile.TemporaryDirectory() as temporary:
api = Api("http://opensearch:9200", "current", Path(temporary))
page = api.review_page()
self.assertIn("state.results.length ? 'Сохранить оценку' : 'Сохранить пустую выдачу'", page)
self.assertIn("aria-required', String(!state.results.length)", page)
self.assertIn("const results = state.results.map((result, index) => { const item", page)
def test_snapshot_and_review_page_are_available(self):
with tempfile.TemporaryDirectory() as temporary:
api = Api("http://opensearch:9200", "current", Path(temporary))
page = api.review_page()
self.assertIn("Оценка поисковой выдачи", page)
self.assertIn("akyldash-search-review-draft", page)
self.assertIn("tutorial-dialog", page)
self.assertIn("Как это работает", page)
self.assertNotIn('id="error"', page)
self.assertIn("localStorage", page)
def test_new_search_snapshots_use_search_8_without_invalidating_search_7(self):
with tempfile.TemporaryDirectory() as temporary:
api = Api("http://opensearch:9200", "current", Path(temporary), Path(temporary) / "reviews.sqlite3", b"test-secret")
with patch("search.api.request_json", return_value={"hits": {"hits": [{"_index": "current-v2", "_source": {"document_code": "7", "document_name_ru": "Закон"}}]}}):
result = api.handle("GET", "/search?q=test&language=ru")[1]
current = api.review_snapshots.verify(result["review_token"])
self.assertEqual(SEARCH_ALGORITHM_VERSION, "search-8")
self.assertEqual(current["algorithm_version"], "search-8")
legacy_token = api.review_snapshots.create(
"legacy query", "ru", "current-v1", [{"rank": 1, "code": "7"}], "search-7"
)
legacy_saved = api.handle("POST", "/search-reviews", {
"review_token": legacy_token,
"reviewer": "Юрист",
"results": [{"rank": 1, "code": "7", "rating": 3}],
})
self.assertEqual(legacy_saved[0], 201)
exported = api.handle("GET", "/search-reviews/export")[1]["reviews"]
self.assertEqual(exported[-1]["algorithm_version"], "search-7")
if __name__ == "__main__":
unittest.main()
+102
View File
@@ -0,0 +1,102 @@
import json
import sqlite3
import tempfile
import unittest
from contextlib import closing
from pathlib import Path
from tools.build_search_topic_taxonomy import build, group_for
class SearchTopicTaxonomyTest(unittest.TestCase):
def test_ambiguous_roots_are_left_for_manual_review(self):
self.assertEqual(group_for("Иностранные инвестиции"), "review_required")
def test_preserves_source_tree_counts_and_marks_unclassified_roots(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
(root / "documents/1").mkdir(parents=True)
(root / "documents/2").mkdir(parents=True)
with closing(sqlite3.connect(root / "manifest.sqlite3")) as db:
db.execute("CREATE TABLE documents (code TEXT, state TEXT)")
db.executemany("INSERT INTO documents VALUES (?, 'success')", [("1",), ("2",)])
db.commit()
classifier = {
"Code": None,
"Name": {"Rus": "ЗАКОНОДАТЕЛЬСТВО О ТРУДЕ", "Kyr": None},
"GeneralClassifiers": [{
"Code": "child-1",
"Name": {"Rus": "Рабочее время", "Kyr": "Иш убактысы"},
"GeneralClassifiers": [],
}],
}
unknown = {
"Code": None,
"Name": {"Rus": "НЕИЗВЕСТНАЯ РУБРИКА", "Kyr": None},
"GeneralClassifiers": [],
}
(root / "documents/1/document.json").write_text(
json.dumps({"general_classifiers": [classifier]}, ensure_ascii=False),
encoding="utf-8",
)
(root / "documents/2/document.json").write_text(
json.dumps({"general_classifiers": [classifier, unknown]}, ensure_ascii=False),
encoding="utf-8",
)
catalog = build(root)
groups = {group["code"]: group for group in catalog["groups"]}
labor_root = groups["labor_social"]["children"][0]
self.assertEqual((catalog["source_document_count"], catalog["source_node_count"], catalog["source_root_count"]), (2, 3, 2))
self.assertEqual(labor_root["document_count"], 2)
self.assertEqual(labor_root["children"][0]["document_count"], 2)
self.assertEqual(groups["review_required"]["children"][0]["grouping_status"], "manual_review")
self.assertEqual(catalog["nodes_missing_kyrgyz_label"], 2)
def test_rebuild_fails_instead_of_omitting_successful_manifest_document(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
(root / "documents/1").mkdir(parents=True)
with closing(sqlite3.connect(root / "manifest.sqlite3")) as db:
db.execute("CREATE TABLE documents (code TEXT, state TEXT)")
db.execute("INSERT INTO documents VALUES ('1', 'success')")
db.commit()
with self.assertRaisesRegex(RuntimeError, "normalized document 1"):
build(root)
def test_rebuild_rejects_normalized_document_without_classifier_list(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
document = root / "documents/1"
document.mkdir(parents=True)
with closing(sqlite3.connect(root / "manifest.sqlite3")) as db:
db.execute("CREATE TABLE documents (code TEXT, state TEXT)")
db.execute("INSERT INTO documents VALUES ('1', 'success')")
db.commit()
(document / "document.json").write_text("{}", encoding="utf-8")
with self.assertRaisesRegex(RuntimeError, "general_classifiers list"):
build(root)
def test_rebuild_rejects_invalid_nested_classifier_branch(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
document = root / "documents/1"
document.mkdir(parents=True)
with closing(sqlite3.connect(root / "manifest.sqlite3")) as db:
db.execute("CREATE TABLE documents (code TEXT, state TEXT)")
db.execute("INSERT INTO documents VALUES ('1', 'success')")
db.commit()
(document / "document.json").write_text(
json.dumps({"general_classifiers": [{"Name": {"Rus": "Корень"}, "GeneralClassifiers": "bad"}]}),
encoding="utf-8",
)
with self.assertRaisesRegex(RuntimeError, "non-list GeneralClassifiers branch"):
build(root)
if __name__ == "__main__":
unittest.main()
+9
View File
@@ -0,0 +1,9 @@
# Absolute path on the production host containing minjust-normalized/.
DATA_ROOT=/var/lib/akyldash/data
REVIEW_SECRET_FILE=/etc/akyldash/review_secret
BACKEND_IMAGE=akyldash/backend:local
OPENSEARCH_IMAGE=akyldash/opensearch:local
SEARCH_INDEX=akyldash-fragments-current
OPENSEARCH_MEM_LIMIT=4g
OPENSEARCH_JAVA_OPTS=-Xms2g -Xmx2g
COOKIE_SECURE=true
+4
View File
@@ -0,0 +1,4 @@
akyldash.kg {
encode zstd gzip
reverse_proxy backend:8080
}
+11
View File
@@ -0,0 +1,11 @@
FROM python:3.12-slim
WORKDIR /app
COPY backend /app/backend
ENV PYTHONPATH=/app/backend
EXPOSE 8080
CMD ["python", "-m", "search.api", "--host", "0.0.0.0", "--port", "8080", "--url", "http://opensearch:9200", "--index", "akyldash-fragments-current", "--data", "/app/data/minjust-normalized", "--reviews-db", "/app/data/search-reviews.sqlite3"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/review', timeout=3)"]
+3
View File
@@ -0,0 +1,3 @@
FROM opensearchproject/opensearch:3.7.0
RUN /usr/share/opensearch/bin/opensearch-plugin install --batch analysis-icu
+164
View File
@@ -0,0 +1,164 @@
# Production deployment
Compose runs Caddy, the Search API, and a private OpenSearch node. Only Caddy
publishes ports `80` and `443`; backend and OpenSearch have no host ports. The
OpenSearch security plugin is disabled to match the current API client, so keep
it on the private network.
## Host access and configuration
The production environment file is `/etc/akyldash/production.env`; the review
secret is `/etc/akyldash/review_secret`. Both must stay outside the checkout,
root-owned, and mode `0600`. `DATA_ROOT` must be an absolute path. The `codex`
account is intentionally outside the `sudo` and `docker` groups because access
to the Docker socket grants root-equivalent control. Run host operations with
the narrowly required `sudo` command; do not use `docker` as `codex` directly.
After reviewing the host audit and confirming these files do not already exist,
the owner can create them from the approved checkout with
`sudo bash infra/deploy/prepare-production-config.sh`. The script refuses to
overwrite existing configuration, installs the tracked env example with mode
`0600`, and generates a random review secret with mode `0600`. It does not
start Compose or create volumes. Review and set `DATA_ROOT` in the protected
env file before deployment.
Set `DATA_ROOT` to the approved data directory and ensure the normalized corpus
is at `$DATA_ROOT/minjust-normalized/`. The first host setup is documented in
[`docs/operations/DEPLOYMENT.md`](../../docs/operations/DEPLOYMENT.md); the
root bootstrap installs packages and creates directories, but does not deploy
the application or alter SSH, firewall, user groups, or sudoers. The restricted
`akyldash-compose` helper allows only root-owned Compose `config` and `status`;
service start/stop remains an owner-run root operation.
The repository Gitea Actions runner is an isolated CI worker described in
[`docs/operations/CI_RUNNER.md`](../../docs/operations/CI_RUNNER.md). It has no
production Docker socket, production env files, or application checkout. CI
does not deploy or change production; all deployment remains owner-run.
## First deployment and upgrades
Run commands from the root-owned checkout at `/opt/akyldash/current`. Render
and review Compose before changing services:
```bash
cd /opt/akyldash/current
sudo docker compose --env-file /etc/akyldash/production.env -f deploy/production/compose.yaml config --quiet
```
On a clean first deployment, `infra/backup/archive.py create` reports that no
backup is required when all persistent mounts are empty and no Compose
containers exist. Continue without a backup in that case. If any persistent
data exists, create and verify a backup before changing schema, images, or
starting a new application version. This also applies when containers are
absent, including an initial installation where the normalized corpus was
preloaded under `DATA_ROOT`; create the archive before the first `up`. When
named volumes do not exist, pass `--initial-deployment`; this is accepted only
without Compose containers and when `DATA_ROOT` contains only
`minjust-normalized/`. During upgrades, missing volumes are an error because
they can indicate lost production data. The archive tool also refuses
ambiguous storage state, running containers, and incomplete Compose service
containers. See
[`docs/operations/BACKUP.md`](../../docs/operations/BACKUP.md) for the exact
commands. Do not remove an older usable backup to make room; retain the
pre-change archive until the new deployment and a newer verified backup are
confirmed.
For an initial clean installation, start the stack only after config review:
```bash
sudo docker compose --env-file /etc/akyldash/production.env -f deploy/production/compose.yaml up -d --build
sudo docker compose --env-file /etc/akyldash/production.env -f deploy/production/compose.yaml ps
curl -fsS https://akyldash.kg/review >/dev/null
```
Before this step, confirm DNS and have the owner review firewall rules for TCP
`80` and `443`. Caddy manages the origin certificate; confirm HTTPS before
setting Cloudflare to Full (strict). On the initial start, create the `admin`
account interactively:
```bash
sudo docker compose --env-file /etc/akyldash/production.env -f deploy/production/compose.yaml exec backend python -m search.api --create-admin
```
The account database is `$DATA_ROOT/search-users.sqlite3` and is included in
the `data` backup. Admin password reset is also interactive:
```bash
sudo docker compose --env-file /etc/akyldash/production.env -f deploy/production/compose.yaml exec backend python -m search.api --reset-admin-password
```
After the initial OpenSearch index is loaded and validated, the `admin` account
is created, and `/review` works over HTTPS, create and verify the required
post-setup baseline backup before treating deployment as complete. It captures
the initialized index, account database, and Caddy certificate data. Follow
the exact stop/create/verify/start sequence in
[`docs/operations/BACKUP.md`](../../docs/operations/BACKUP.md#initial-post-setup-baseline).
## Backup, restore, and rollback
Backups are cold and require downtime. Stop services, create the archive, then
verify both its checksum and contents before proceeding. Do not run an upgrade
or schema change until verification succeeds. Keep archives root-only under
`/var/backups/akyldash` and copy them to separately managed encrypted storage.
Restore is a staged operation on a marked replacement host; it does not
overwrite production. For application-only rollback, use the guarded helper
with a verified pre-release backup and explicit data-compatibility review.
Follow the procedures in [`BACKUP.md`](../../docs/operations/BACKUP.md),
[`ROLLBACK.md`](../../docs/operations/ROLLBACK.md), and
[`RECOVERY.md`](../../docs/operations/RECOVERY.md).
Automated production deployment is not enabled. Deployments remain owner-run;
record the release and verify service health before considering it complete.
## Image release and owner-run cutover
PR CI still builds both production images with Kaniko `--no-push`. The separate
`Release production images` workflow is manual and runs only when dispatched
from protected `main`. It requires repository secrets named
`REGISTRY_USERNAME` and `REGISTRY_PASSWORD`; without them it fails before any
build. Configure those secrets only after the owner confirms a dedicated
Gitea account/token with write access to the two repository packages and no
unneeded administration rights. Do not reuse a runner or production host
credential. The workflow uses the Gitea host and fixed package namespace
`git.vakanaut.ru/admin/akyldash/{backend,opensearch}`, pushes unique
commit/run-ID tags, captures registry digests, validates the release
descriptor, and uploads the JSON plus SHA-256 sidecar as a Gitea Actions
artifact using a full-SHA-pinned Gitea-compatible v3 action. PR CI also uploads
a tiny synthetic marker with one-day retention to verify the runner's runtime
artifact path without registry credentials. The runner cache stays disabled;
artifact compatibility is based on the runtime API supported by act_runner
0.2.13/Gitea 1.25.5 and will be empirically checked by PR CI. Registry and
artifact retention must preserve release digests and descriptors for the full
rollback window; the owner must confirm both policies.
The schema-v3 descriptor records source repository/ref/commit, workflow run ID,
pinned Kaniko builder and both image digests. It is an unsigned trace record,
not a cryptographic attestation; deployment relies on retrieving it from the
authenticated Gitea run artifact and verifying its checksum and repository
allowlist. Do not treat the sidecar alone as proof of authenticity.
For a future owner-approved cutover, use the root-owned checkout and run
`infra/deploy/deploy_release.py` as root with the downloaded manifest, its
matching verified cold backup, and `infra/deploy/release-policy.json`, plus
owner-verified non-empty Russian and Kyrgyz canary queries, for example
`--canary-ru '<known Russian query>' --canary-ky '<known Kyrgyz query>'`.
The helper checks both queries against the public Search API through local
HTTPS after Compose reports healthy, and requires at least one result with a
document code before recording the new release state. Select queries only
after corpus/index canaries have been verified; do not use generic terms that
can match unrelated test data. The helper requires the backup image references and image IDs to match the live
stack, serializes deploy and rollback with `/run/akyldash-deploy.lock`,
atomically switches both protected env image refs, and always runs Compose with
`--no-build`. It records recovery state before changing references. If ref
replacement reports an error after an atomic rename, recovery rereads the
actual file and restores the prior pair before restarting; an unexpected or
unverifiable pair stops recovery and leaves a secret-free journal. A failed
cutover refuses a subsequent release until an owner resolves the journal.
Rollback accepts a digest-pinned previous release
only when that backup matches the separately recorded previous refs and image
IDs. Legacy tag rollback remains supported. Neither the helper nor registry
secrets are installed on the VPS by this PR; deployment remains an owner-only
operation and must wait for host/data/backup review. The exact sanitized
evidence required from the owner is listed in
[`DEPLOYMENT_OWNER_PACKET.md`](../../docs/operations/DEPLOYMENT_OWNER_PACKET.md).
+124
View File
@@ -0,0 +1,124 @@
name: akyldash
services:
caddy:
image: caddy:2.11.6-alpine@sha256:881bbc60f9986d5ab8e7cfd6cf7e4ef3c9c0439fef2429d035d065577882f028
restart: unless-stopped
logging:
driver: local
options:
max-size: 10m
max-file: "3"
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
networks:
- proxy
depends_on:
backend:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:2019/config/"]
interval: 30s
timeout: 5s
retries: 3
opensearch:
image: ${OPENSEARCH_IMAGE:-akyldash/opensearch:local}
build:
context: ../..
dockerfile: deploy/production/Dockerfile.opensearch
restart: unless-stopped
logging:
driver: local
options:
max-size: 10m
max-file: "3"
environment:
discovery.type: single-node
bootstrap.memory_lock: "true"
DISABLE_SECURITY_PLUGIN: "true"
OPENSEARCH_JAVA_OPTS: ${OPENSEARCH_JAVA_OPTS:--Xms2g -Xmx2g}
mem_limit: ${OPENSEARCH_MEM_LIMIT:-4g}
expose:
- "9200"
ulimits:
memlock:
soft: -1
hard: -1
nofile:
soft: 65536
hard: 65536
volumes:
- opensearch-data:/usr/share/opensearch/data
networks:
- search
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:9200/_cluster/health || exit 1"]
interval: 30s
timeout: 10s
retries: 10
backend:
image: ${BACKEND_IMAGE:-akyldash/backend:local}
build:
context: ../..
dockerfile: deploy/production/Dockerfile.backend
restart: unless-stopped
logging:
driver: local
options:
max-size: 10m
max-file: "3"
environment:
COOKIE_SECURE: ${COOKIE_SECURE:-true}
command:
- python
- -m
- search.api
- --host
- 0.0.0.0
- --port
- "8080"
- --url
- http://opensearch:9200
- --index
- ${SEARCH_INDEX:-akyldash-fragments-current}
- --data
- /app/data/minjust-normalized
- --reviews-db
- /app/data/search-reviews.sqlite3
- --users-db
- /app/data/search-users.sqlite3
- --review-secret-file
- /run/secrets/review_secret
secrets:
- review_secret
expose:
- "8080"
depends_on:
opensearch:
condition: service_healthy
volumes:
- ${DATA_ROOT:?set DATA_ROOT in .env}:/app/data
networks:
- proxy
- search
volumes:
caddy-data:
caddy-config:
opensearch-data:
networks:
proxy:
search:
internal: true
secrets:
review_secret:
file: ${REVIEW_SECRET_FILE:?set REVIEW_SECRET_FILE in .env}
+21 -1
View File
@@ -2,12 +2,23 @@
## Продукт
- [Дизайн-система «Түндүк»](../DESIGN.md) — утверждённые палитра,
типографика, компоненты и правила адаптивности портала и лаборатории.
- [Обзор проекта](product/project-overview.md) — назначение, основные области и
правила работы с данными.
- [Стратегическое сравнение с Токтом](product/platform-development-plan-toktom-2026-10-24.md) —
продуктовая гипотеза и конкурентный анализ; это не план исполнения задач.
- [План frontend поисковой СПС](product/frontend-search-sps-plan.md) — границы
MVP, зависимости и спринты.
- [Готовность к проектированию frontend](product/frontend-design-readiness-plan.md) —
обязательные работы и критерии перехода к frontend.
- [План реализации и приёмки](product/implementation-roadmap.md) — единая
очередность UI и deployment gates, со статусом доказательств; не дублирует
продуктовую стратегию или эксплуатационные процедуры.
- [План интерфейса оценки поисковой выдачи](product/search-relevance-review-interface-plan.md) —
внутренняя лаборатория сбора оценок юристов для настройки OpenSearch.
- [Разметка relevance set](../backend/search/RELEVANCE_ANNOTATION.md) — подготовка
эталонных документов и воспроизводимая оценка собственного поиска.
- [Задание по нормализации документов](product/minjust-document-normalization-agent-task.md) —
требования и критерии приёмки нормализатора ЦБД Минюста КР.
@@ -20,6 +31,15 @@
- [Текущий статус](operations/project-status.md) — выполненные шаги, риски и
ближайшие действия.
- [Состояние инфраструктуры](operations/CURRENT_STATE.md) — подтверждённое
состояние production и оставшиеся проверки.
- [Инфраструктура и эксплуатация](operations/INFRASTRUCTURE.md) — схема,
доступы, deployment, backup, rollback и recovery.
- [Операционные процедуры](operations/DEPLOYMENT.md) — связанные инструкции по
deployment, [backup](operations/BACKUP.md), [rollback](operations/ROLLBACK.md),
[recovery](operations/RECOVERY.md), обязательная процедура
[передачи корпуса](operations/CORPUS_HANDOFF.md) и
[пакет владельца](operations/DEPLOYMENT_OWNER_PACKET.md).
- [План Telegram-пространства](operations/telegram-workspace-plan.md) — темы,
роли и этапы развития командного окружения.
@@ -35,4 +55,4 @@
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.6.0 · Frontend — не создан
Акылдаш · Telegram-бот v0.2.2 · Backend v0.13.4 · Frontend v0.3.4
+1 -1
View File
@@ -78,4 +78,4 @@ Telegram позволяет запретить пользователям отп
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.6.0 · Frontend — не создан
Акылдаш · Telegram-бот v0.2.2 · Backend v0.7.1 · Frontend — не создан
Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

+177
View File
@@ -0,0 +1,177 @@
# Delivery First: проверка изменений по риску
Эта политика помогает быстро принимать небольшие изменения и добавляет
проверки там, где ошибка затрагивает пользователей, данные или границы
безопасности. Класс риска выбирает глубину проверки; он не позволяет снять
обязательную проверку, одобрение или защиту `main`.
## Порядок работы
1. До реализации укажи в PR `LOW`, `MEDIUM` или `HIGH` и коротко объясни
причину. Если изменение пересекает несколько областей, выбери самый высокий
класс. При неопределённости используй более высокий класс.
2. Сначала проследи путь изменения от пользовательского/API входа до данных,
внешних систем и отката. Для изменения поведения добавь регрессионный тест,
который подтверждает конкретный контракт или прежний дефект.
3. Запусти проверку именно затронутого поведения и обычный Gitea CI. Для UI
дополнительно выполни `cd tools/frontend-visual-review && npm ci && npm test`;
этот браузерный набор сейчас не входит в Gitea CI.
4. В PR перечисли класс риска, команды и результаты, остаточные ограничения,
а для high-risk — совместимость, угрозы и безопасный rollback. Не заявляй
проверку, которой фактически не было.
5. Ревьюер отделяет блокирующие дефекты от улучшений. Блокер должен указывать
конкретное место, условие воспроизведения и последствие. Стилистические
предпочтения и предложения на будущее фиксируются отдельно и не задерживают
PR.
## Классы риска
| Класс | Когда применять | Требуемое подтверждение |
|---|---|---|
| `LOW` | Документация, комментарии, переименование без изменения поведения, форматирование без изменения конфигурационной семантики. Нет изменения контракта, доступа, данных или исполнения. | Проверить diff и сверить утверждения/команды с исходным кодом и действующей процедурой. Выполнить обычный CI. Если документация меняет операторский шаг, проверить его в безопасном локальном или synthetic окружении. |
| `MEDIUM` | Ограниченное изменение поведения одного компонента, внутреннего API, UI или локального скрипта без изменения модели доверия и формата хранимых данных. | Целевые тесты затронутого компонента и регрессионная проверка, если меняется поведение; обычный CI. Для UI приложить результаты браузерного набора и synthetic визуальной проверки, если они относятся к изменённому экрану/потоку. Проверить обратную совместимость публичных запросов и ответов. |
| `HIGH` | Авторизация и роли; secrets и credential flow; ingestion и происхождение данных; формат/миграция хранилища; backup/restore/recovery; production config; runner, CI и supply chain; безопасность; сетевые/host-границы; deploy и rollback. | Обычный CI плюс целевые проверки каждого затронутого trust/data boundary; regression-тест; проверка негативного пути и совместимости; независимое ревью конкретных угроз и последствий. Для операций с данными использовать synthetic/staging данные и план отката. Изменение production допускается только владельцем после отдельного согласования, проверки backup и готового rollback. |
Нельзя понижать класс из-за малого diff, срочности или простоты реализации:
маленькое изменение правила доступа или восстановления всё равно `HIGH`.
Нельзя считать отсутствие найденных тестов доказательством отсутствия риска.
## CI и ограничения Gitea
Проверенная конфигурация проекта:
- Репозиторий `admin/akyldash`, Gitea API вернул `1.25.5` 2026-10-24.
- Workflow `.gitea/workflows/ci.yaml` запускается на `pull_request` и `push` в
`main`; job ID, который следует сохранять для совместимости статусов, —
`tests-and-images`.
Он последовательно выполняет backend, Telegram, backup/restore, shell guards,
deployment tests, Compose/Caddy validation и две production image builds.
- `.gitea/workflows/runner-smoke.yaml` сохраняет отдельный job ID `runner-smoke`
и проверяет runtime-изоляцию job-контейнера: отсутствие системного и
rootless Docker socket, production env/secrets и host checkout. Smoke дополняет
статические проверки runner/CI и не дублирует их.
- Инфраструктурная конфигурация репозитория закрепляет официальный
`act_runner 0.2.13`, label `akyldash-ci`, capacity 1 и ограничения job 2 CPU,
3 GiB RAM, 512 процессов, 15 GiB store. Версия runner и лимиты приведены по
IaC; в рамках этой задачи VPS не проверялся. Job-контейнеры не должны получать
ни один Docker socket.
- Текущая политика `main` из branch API: ветка защищена и требует одного
одобрения. На момент проверки API также сообщил `enable_status_check=false`
и пустой список status contexts. Это PR не меняет branch protection.
Если владелец отдельно включит обязательные статусы, сначала проверьте их
точные контексты: `CI / tests-and-images (pull_request)` и
`Runner smoke test / runner-smoke (pull_request)`. Не переименовывайте job IDs.
Функции и границы текущих проверок:
| Блок workflow | Назначение и риск пропуска | Перекрытие |
|---|---|---|
| Checkout и Python setup | Подготовка повторяемой среды; пропуск может дать ложный успех на неподходящем образе. | Инфраструктурный шаг, не покрывается тестами приложения. |
| Backend и Telegram tests | Регрессии API, логики поиска/аккаунтов и Telegram bot. Риск продуктовый; для auth/data изменений — `HIGH`. | Разные пакеты и контракты; отдельная ветка тестов. |
| Backup/restore tests | Целостность backup, восстановление и отказные сценарии; риск потери данных — `HIGH`. | Не дублируется тестами backend. |
| Bootstrap/recovery/verifier/host-audit/production-config guards | Отказ от root и защиты host identity, аргументов и production paths; обход может дать административное действие или затронуть host — `HIGH`. | Несколько guard-тестов проверяют разные скрипты и условия; считать их дублями без анализа каждого контракта нельзя. |
| Runner config, installer guards и CI container isolation | Ограничения runner, repeatability установки и запрет socket/privileged/host mounts; риск — доступ к host/production — `HIGH`. | Статические/синтетические проверки дополняются реальным runtime smoke, это разные уровни. |
| Shell syntax и Python AST parse | Раннее обнаружение ошибок парсинга; риск пропуска — поломка запуска скрипта/проверки. | Это синтаксис, а не функциональные тесты. |
| Deployment contract/rollback tests, Compose config и Caddy validation | Совместимость deploy contract/rollback и конфигурации production stack/proxy; риск — deploy outage/data impact — `HIGH`. | Compose проверяет интерпретацию конфигурации, Caddy — синтаксис proxy, Python suite — contract/rollback logic. Функции отличаются. |
| Две Kaniko image builds | Проверка Dockerfile и воспроизводимости backend/OpenSearch production images; риск — сломанная сборка/небезопасный путь supply chain — `HIGH`. | Два Dockerfile — две разные целевые images; build-only, оба `--no-push`. |
| Runner smoke (отдельный workflow) | Runtime-проверка известного системного socket path, production paths/secrets и host checkout отсутствия в job; риск — нарушение изоляции — `HIGH`. | Дополняет статические runner/CI guards; не повторяет их. |
Gitea API не предоставляет пригодный список steps для завершённого run в этой
версии; длительность каждой строки таблицы поэтому `N/A`. Не распределяй общий
CI runtime между блоками пропорционально числу тестов или строк кода.
Gitea 1.25 документация подтверждает используемые события `push`,
`pull_request` и `workflow_dispatch`. Она также документирует ограничения,
которые запрещают строить безопасное ускорение на привычных GitHub Actions
предположениях:
- `runs-on` поддерживает статическую метку или список меток; текущая
`akyldash-ci` — статическая метка.
- Выражения доступны ограниченно; в сравнении с GitHub Actions Gitea 1.25
обещает функцию `always()`, поэтому workflow не должен зависеть от
`if:`-выражений для динамического выбора набора тестов по diff или уровню
риска.
- `jobs.<job_id>.timeout-minutes`, `permissions`, `concurrency` и
`jobs.<job_id>.environment` в Gitea 1.25 игнорируются. Не используйте их как
защиту, срок исполнения, обязательный approval или средство экономии.
- У runner capacity 1. Разделение текущей последовательной работы на
параллельные jobs не даёт измеримого параллельного исполнения на этом runner
и может добавить запуск/очередь. Сохраняйте проверку сборки обеих production
images: она подтверждает Dockerfiles, но использует Kaniko с `--no-push` и не
публикует образы.
Источники семантики: [Gitea 1.25 Actions comparison](https://docs.gitea.com/1.25/usage/actions/comparison/),
[Gitea 1.25 Actions FAQ](https://docs.gitea.com/1.25/usage/actions/faq/) и
[act_runner guide](https://docs.gitea.com/1.25/usage/actions/act-runner/).
Перед использованием новой workflow-семантики сверяй её с документацией
установленной версии и проверяй фактическим run. Не добавляй проверки самой
этой политики или статические тесты, утверждающие её текст.
## Временные ориентиры и фактические измерения
Ориентиры — цель для поиска затрат, а не разрешение убрать тесты:
- Обычный полный CI: до 5 минут от старта job до завершения. При превышении
сначала измерить и оптимизировать узкий участок, сохраняя тесты и изоляцию.
- Расширенный путь для `HIGH` (обычный CI плюс целевые проверки): целевой
суммарный интервал 10–15 минут. Это пока ориентир, а не отдельный
автоматический workflow или установленный timeout.
Срез 2026-10-24 получен из документированного [Gitea Actions task API](https://docs.gitea.com/api/1.25/operations/list-action-tasks/)
`/repos/admin/akyldash/actions/tasks?limit=50`. Взяты 20 последних завершённых
`tests-and-images`; длительность оценена как `updated_at - run_started_at`:
| Метрика | Измерение |
|---|---:|
| CI completed sample | 20 прогонов, 20 success |
| CI median | 147.5 s (2:27.5) |
| CI p90 | 172 s (2:52) |
| CI max | 192 s (3:12) |
| Runner smoke median / max | 2 s / 3 s, последние 20 прогонов |
Прежней reconciliation или оценок длительности в доступной документации
репозитория, workflows, PR и issues не найдено; этот срез является первой
подтверждённой базой, и сравнить его с прежней оценкой нельзя.
Все 20 измеренных полных CI прогонов ниже пяти минут; текущая конфигурация уже
достигает обычного ориентира. Отдельного extended workflow нет, поэтому
10–15-минутная цель пока не имеет фактической базы. В выборке нет breakdown по
steps: документированный endpoint для jobs завершённого run возвратил пустой
список; это известное ограничение Gitea 1.25.5 ([upstream issue #38286](https://github.com/go-gitea/gitea/issues/38286)).
Не распределяй время между тестами и image builds по предположению. При будущих
изменениях workflow записывай доступные timestamps и точный размер выборки;
после изменения Gitea/runner перепроверь, стал ли доступен надёжный step timing.
## Метрики потока поставки
Для каждого релиза/месячного обзора отдельно собирать:
| Метрика | Определение и источник |
|---|---|
| Ready → merge | Время с момента, когда PR реально готов к ревью, до merge. Не подменять датой создания draft PR; при отсутствии точного ready timestamp записывать `N/A`. |
| Merge → deploy | Merge timestamp до фактического production deploy timestamp. Deploy пока не автоматизирован; пока нет подтверждённого deploy evidence, записывать `N/A`, не ноль. |
| CI duration | `run_started_at` → task completion (`updated_at`) по Actions API; отдельно указывать медиану, p90, максимум, период и число прогонов. |
| Повторные прогоны | Число неуспешных/повторных прогонов на commit до первого успеха; отличать rerun от нового commit/push. |
| Блокирующие замечания | Число ревью-находок, удовлетворяющих критериям blockers выше; неблокирующие считать отдельно. |
| Вышедшие регрессии | Подтверждённые после merge дефекты, связанные с изменением; фиксировать компонент, тяжесть и пропущенный сигнал. |
| Rollback | Количество фактически выполненных rollback, причина и результат проверки данных/сервиса. Не считать написанный план фактическим rollback. |
На момент этого аудита PR/API не дают надёжного ready timestamp для старых
изменений; production deployment не автоматизирован, а задачи этого PR не
содержат deploy telemetry. Поэтому ready→merge, merge→deploy, escaped
regressions и rollback baseline остаются `N/A`, пока не появится проверяемая
запись. Не придумывай значения из `created_at` или даты последнего update.
## Критерий блокирующего ревью
Блокирующие замечания ограничены случаями:
- воспроизводимый дефект с конкретным условием и последствием;
- расширение доступа, утечка секрета или обход границы runner/production;
- потеря/искажение данных или несовместимость API/storage без безопасного пути;
- обязательный тест или подтверждение из таблицы риска отсутствует/не проходит;
- опасная операция не имеет проверяемого backup/rollback плана.
Каждый блокер содержит файл/участок, шаги воспроизведения или точное условие,
последствие и минимальный безопасный fix. Остальные замечания — отдельный
неблокирующий список. Review и approval остаются за людьми; агент не начинает
review сам и не сливает PR.
+84
View File
@@ -0,0 +1,84 @@
# Infrastructure access
## Discover existing access first
In a new Codex session, inspect the SSH alias and key file metadata without
printing key contents, then check the connection:
```bash
ssh -F ~/.ssh/config -G akyldash_vps | awk '$1 == "hostname" || $1 == "user" || $1 == "port" || $1 == "identityfile" { print }'
test -r ~/.ssh/akyldash_codex && echo 'SSH key is available'
ssh -F ~/.ssh/config -o BatchMode=yes akyldash_vps id
ssh -F ~/.ssh/config -o BatchMode=yes akyldash_vps hostname -f
gitea-api GET /version
gitea-api GET /repos/admin/akyldash
```
Do not ask for the host, login, password, token, or key again before these
checks. Never print a private key, token, or complete environment file.
## VPS
- SSH alias: `akyldash_vps`, user `codex`, host `206.223.249.118`, port `22`.
- SSH configuration: `~/.ssh/config`.
- Identity file: `~/.ssh/akyldash_codex` (path only; never copy its contents).
- `codex` can run unprivileged read-only diagnostics. It has no sudo or Docker
group membership; Docker CLI access to the daemon is denied. Do not add it to
either group.
- Bootstrap is complete. Docker and Compose are active, but no root-owned
repository checkout or restricted helper is installed yet. The owner can run
`infra/bootstrap/audit-host.sh` from the reviewed root-owned checkout to
report Docker containers/volumes, protected-file metadata, UFW rules, ports,
and ISPmanager status without showing secret contents or changing state.
- Host package/service configuration, firewall changes, protected env/secret
creation, backup creation, recovery apply, and production deployment require
a reviewed owner/root command. `infra/deploy/install-helper.sh` grants the
fixed verifier, read-only Compose `config`/`status`, and guarded rollback
commands. It does not grant general sudo or Docker access; it is not
installed on the VPS.
- `infra/deploy/prepare-production-config.sh` is an owner-run root operation.
It refuses to overwrite existing files, copies only the tracked env example,
generates a random review secret, and writes both files with mode `0600`.
It does not start Compose or create volumes.
- Never connect to other VPS hosts or Synology as part of this deployment.
This execution environment is WSL2. The repository is on the Windows-mounted
`/mnt/d` filesystem, while SSH config and the identity file are in the Linux
home directory. Do not assume Windows SSH credentials are available or copy
keys between environments. Plain `ssh akyldash_vps` failed because the system
SSH config include had unsafe permissions. Use `ssh -F ~/.ssh/config ...`; in a
network-restricted session this read-only command may also require approved
execution. If the key is missing in a future environment, report that
limitation and do not retrieve it elsewhere.
## Gitea
- Repository: `https://git.vakanaut.ru/admin/akyldash`.
- Use the installed `gitea-api` helper; it reads credentials from the
environment. Never inspect local credential files, print environment values,
or construct HTTP authorization headers.
- The audited API token can read/write this repository and create issues, but
does not have `read:user` or `read:package`. Listing repository Actions
runners returns `403` because the API principal is not the repository owner.
An API `403` caused by missing scope/ownership is not evidence that no runner
exists.
- Protected `main` requires one approval. Do not push directly to `main`, merge
automatically, or start a review. Work on a feature branch and use a PR.
- Gitea Actions and package features are enabled for the repository. The Actions
task API currently returns no runs, but runner availability and package
publishing permissions have not been confirmed. The repository-level runner
is prepared by `infra/runner/install-runner.sh`, but is not installed or
registered yet. Only a repository owner may obtain its registration token in
`admin/akyldash` Settings → Actions → Runners; do not use organization or
instance tokens and never put the token in Git, a command argument, or a
report. See [CI_RUNNER.md](CI_RUNNER.md).
## Who can do what
| Role | Operations |
|---|---|
| `codex` over SSH | Unprivileged diagnostics; Docker daemon, UFW rules, and protected paths require the root audit script |
| Restricted helpers | Read-only drift check and Compose `config`/`status`, plus guarded rollback; not installed on the audited VPS |
| Restricted rollback helper | Can restore prior local backend/OpenSearch images from a verified backup after an explicit data-compatibility confirmation; not installed on the audited VPS |
| VPS owner/root | Bootstrap, package installation, SSH/firewall, secret setup, recovery, and approval of production changes |
| Gitea repository writer | Feature branch push and PR creation; protected-main merge needs an approval |
+80
View File
@@ -0,0 +1,80 @@
# Backup and restore
## Current status
The owner requires that a complete production archive never be written to the
VPS, even temporarily. The reviewed implementation in PR #59 streams stopped
Compose state directly from the root receiver to the owner's Windows client;
the client stores it only under `D:\Dev\akyldash\.backups`. The VPS retains
only a secret-free identity (backup ID, size, archive and manifest hashes,
manifest) in root-owned release state. The Windows rerun passed 12 synthetic
client checks, 8 protocol checks, and a disposable real-NTFS private owner-ACL
fixture. The production D: path, SSH connection, VPS installation, and
full production backup remain unverified. There is no verified production
backup, so production deployment remains blocked.
## External backup flow
- The release receiver takes the shared deployment lock and stops the stack.
- It streams a tar/gzip archive through a bounded binary SSH session. It does
not create a local archive or temporary full-size copy on the VPS.
- The Windows receiver requires the exact D: repository, a real NTFS path,
restrictive ACLs, and the exact `/.backups/` rule in that checkout's local
`.git/info/exclude`. Before SSH connects, it also requires at least 100 GiB
plus 1 MiB of free D: space, matching the protocol's maximum archive size.
The ACL owner and every allowed full-control entry must be the current user,
SYSTEM, or local Administrators; other and deny entries fail closed.
It writes into a private staging directory, checks the
stream size and SHA-256, verifies the archive and manifest, then publishes
the complete backup directory without overwriting an existing ID. It checks
the published ACL before acknowledging; on failure it removes only the same
verified staging directory, and refuses cleanup if its identity or contents
have changed.
- Only after Windows confirms the stored archive identity does the VPS proceed
with image cutover. If backup transport or verification fails, it attempts
to restart the previous services and records a recovery journal if restart
fails.
- Rollback first challenges Windows with the root-pinned previous backup
identity. Windows verifies the local archive and streams it back; the VPS
compares its exact size and SHA-256 before stopping services. Rollback then
creates a fresh external backup of the current state before changing image
references. It restores images only, never application data or OpenSearch
state.
Windows PowerShell is the intended client. The Windows checks exercised the
destination and ACL rules, including a disposable real-NTFS private owner-ACL
fixture. The actual production D: destination and SSH path remain unverified.
An owner must separately test archive restore on an isolated target before
relying on this path. Do not restore over production as a test.
## Backup contents and limits
The existing `infra/backup/archive.py` manifest records the Git revision,
deployment-file hashes, image references and IDs, and the components included
in the archive: application data, OpenSearch, Caddy data/configuration,
`production.env`, and the review secret. Secret-bearing files and SQLite files
are archived with restrictive modes. The archive verifier rejects unsafe
members and checks SQLite integrity. SSH encrypts archive bytes in transit.
The destination contains secrets and private keys; D: encryption at rest (for
example, BitLocker) has not been verified. This PR adds no client-side
encryption or signing, so the owner must verify storage encryption and retain
the restrictive ACLs before using it.
The external protocol caps control frames at 64 KiB, chunks at 1 MiB, and the
archive at 100 GiB. SSH provides transport encryption and server host-key
verification. The receiver uses a fixed SSH command and does not accept a
caller-supplied path or shell command. It relies on the existing owner SSH
configuration; no key, host entry, sudo rule, or VPS setting is added here.
## Restore
Use `infra/backup/archive.py restore` only with an archive already present in a
protected owner-controlled location, and target a new staging directory on an
isolated recovery host. Review [recovery](RECOVERY.md) and
[deployment](DEPLOYMENT.md) first. This PR does not perform or authorize a
production restore, data migration, or VPS operation. Keep the previous
external archive until the owner has verified the replacement and explicitly
approved its retention period.
The former `/var/backups/akyldash` create/verify procedure is superseded and
must not be used for a full production archive on the VPS.
+256
View File
@@ -0,0 +1,256 @@
# AkylDash Gitea Actions runner
## Scope and isolation
The deployment follows the official [Gitea 1.25 act_runner guide](https://docs.gitea.com/1.25/usage/actions/act-runner/)
for repository-scoped registration. Gitea warns that access to a Docker daemon
is root-equivalent; the dedicated rootless daemon is used instead of the
production daemon. The pinned binary and checksum are published in the
[official Gitea download directory](https://dl.gitea.com/gitea-runner/0.2.13/).
The runner is reserved to repository `admin/akyldash`, using the token under
`https://git.vakanaut.ru/admin/akyldash/settings/actions/runners`. Do not use
organization or instance runner settings. Pin the official `act_runner`
`0.2.13` x86_64 binary from `dl.gitea.com`; the installer verifies its SHA-256
before placing it on the host.
CI jobs run in `docker.gitea.com/runner-images:ubuntu-24.04` containers with
capacity 1, per-job limits of 2 CPU, 3 GiB RAM and 512 processes, and a 15 GiB
dedicated ext4 loop-backed Docker data filesystem. The runner account's home
directory is root-owned mode `0711`, so CI cannot rename the image or mountpoint
entries. Docker config remains under `.config`; registration state and the
runner workspace live in `.config/act-runner`, which is runner-owned. The
rootless Docker setup and user service use `.config/act-runner` as writable
`HOME`, `.config` as `XDG_CONFIG_HOME`, and
`.config/act-runner/.local/share` as `XDG_DATA_HOME`. This keeps Docker config
and its systemd user unit in the existing `.config` tree without granting
write access to the protected home.
The runner systemd unit uses `ProtectHome=read-only` so `/run/user` remains
visible; `ReadWritePaths` retains the existing `/run/user/$uid` writable
carve-out for the runner's rootless runtime. `ProtectHome=yes` would hide
`/run/user` and break the configured runtime and Docker socket paths. See the
[systemd.exec documentation](https://www.freedesktop.org/software/systemd/man/255/systemd.exec.html#ProtectHome=).
The rootless Docker daemon and runner user are separate from production Docker;
`codex` is not added to
sudo or Docker groups. Jobs do not receive the rootless Docker socket, the
production Docker socket, production environment/secrets, or arbitrary host
bind mounts. The runner account has no access to `/etc/akyldash` or the
root-owned application checkout. These are host permission boundaries, not a
claim that untrusted project code is safe; review workflow changes and actions
before merging them.
The runner service restarts on failure and boot. Systemd caps the runner
account's shared slice to 2.5 CPU, 4 GiB RAM and 2048 tasks; the rootless
daemon is separately capped to 2 CPU, 4 GiB and 1024 tasks, while each job has
the limits above. Runner logs go to a rotated file (10 MiB, 7 copies). A
systemd timer checks the runner service and the rootless Docker API each minute;
on failure it restarts the fixed runner service. No UFW changes or public
metrics ports are needed. Runner cache is disabled.
The CI workflow and `runner-smoke.yaml` target the repository runner label
`akyldash-ci`. CI keeps the backend, Telegram, backup/restore, deployment,
bootstrap, runner, Compose, and Caddy checks. Caddy validation runs in a pinned
container action with explicit `/usr/bin/caddy` entrypoint. The workflow passes
Gitea's `${{ github.workspace }}` value into each container action; Caddy and
Kaniko use that supplied path for repository files instead of assuming a
container workspace location. Both production
Dockerfiles are built in a separate pinned Kaniko container action with explicit
`/kaniko/executor` entrypoint using the runner's rootless container engine; the
job container receives no Docker socket. Kaniko uses `--no-push` and
`--no-push-cache`, so CI validates the full backend and OpenSearch builds but
does not publish images or export build output. The two builds run sequentially
within one job. CI run 69 completed both builds historically; the latest
verified main CI run 175 and smoke run 176 succeeded on `b5adc092`. Actual per-action
resource-limit enforcement and peak memory or disk usage were not independently
measured or verified. No registry credentials, production service secrets,
elevated container options, or arbitrary host mounts are configured in PR CI.
The Kaniko action uses the `linux/amd64` v1.28.5-debug manifest
`sha256:c73ca67a3b494bce79a8fb6090c4f73c18997ab272d80bffc460a62343aeb412`,
listed in the [official GitHub Packages entry](https://github.com/osscontainertools/kaniko/pkgs/container/kaniko/1271366984?tag=v1.28.5-debug).
The pinned Caddy image digest remains the same as the former `docker run`
validation. act_runner starts these container actions; they do not expose its
rootless Docker socket to workflow steps.
Production deployment is not enabled. `release-images.yaml` now starts only
after a successful `CI` workflow run for a push to `main`; it checks the source
workflow, event, branch, conclusion, and SHA and checks that the SHA is still
the current `main` before exposing Registry credentials. It checks out that
exact source SHA and uses the release workflow's own run ID for image tags and
the schema-v3 descriptor. PR CI remains no-push and receives no Registry or
production credentials.
Image publication still requires the existing `REGISTRY_USERNAME` and
`REGISTRY_PASSWORD` Actions secrets, which are not configured or verified.
When configured, they must be package-write-only for the two approved image
names. The release descriptor is unsigned. The SHA-256 is included in the
artifact name so a later workflow can identify the expected digest from
artifact metadata without treating job output as trusted input. The existing
SHA-256 sidecar remains inside the artifact. The workflow has not yet run with
Registry credentials, and the sequential Kaniko build's peak resources have not
been measured beyond the configured 2 CPU, 3 GiB RAM, 512-process job limits.
`deploy-handoff.yaml` runs after a successful release on `main`. It rechecks the
source run and stale-SHA guard. Its current mode is explicitly dry-run: it may
read JSON artifact metadata using the installed `gitea-api` helper when present,
but it never downloads the ZIP or opens an SSH connection. If the helper is not
present in the job image, it reports that artifact metadata and host handoff
remain unverified. No SSH, sudo, production secret, or Docker socket is passed
to the runner.
Gitea 1.25.5 documents a run-artifact metadata endpoint and a ZIP endpoint that
redirects to a blob URL. The currently audited `gitea-api` helper supports JSON
GET requests but has no binary output option and does not follow redirects, so
it cannot yet fetch and independently verify the host-side artifact. Do not
enable a live handoff until an owner-approved binary-safe retrieval capability
validates HTTPS redirects and never forwards the API token across origins. The
host must independently validate the successful `main` release run, artifact
name and ZIP sidecar before any guarded deployment. The owner must also verify
artifact and Registry retention covers the rollback window.
## Installation and registration
Review the root script and host audit output before running it. It installs
`uidmap`, `dbus-user-session`, `curl`, `logrotate`, and Docker's rootless extras;
creates the dedicated system user and selects the first free, shared 65536-ID
range from `/etc/subuid` and `/etc/subgid`; reserves 15 GiB
for runner images/workspaces; and configures systemd services. It starts only
the separate rootless Docker daemon; the runner and health timer stay disabled
until repository registration. It does not change
SSH/UFW, production Docker configuration, application data, or production
secrets. It can be rerun after partial setup: it validates and reuses an
existing runner account, subordinate ID mappings, image, and mount unit. An
existing image must be exactly 15 GiB and contain ext4; a mismatch stops the
installer without resizing or formatting it. The runner account must retain
a non-root system UID below `UID_MIN`, its expected home, primary group, and
sole supplementary group. After validating the account, the installer locks
its home directory to root ownership and mode `0711`; it refuses to rerun while
the runner service is active, so it cannot interrupt a job. The mount
is started only when it is not already active, and an active mount must be the
expected ext4 loop device backed by the runner image with both `nosuid` and
`nodev` active. Runner-home directories and user configuration files are
created in runner-owned subdirectories. Root creates only the first `.config`
entry beneath the already locked home; subsequent directory and config writes
run as `akyldash-runner`. The installer rejects static symlinks and validates
ownership and mode after setup. This also normalizes the `0755` intermediate
directories left by the first installer version. Conflicting files or state
stop the installer for manual inspection. A new image requires at least 25 GiB
free on `/`. Image creation opens each parent component without following
symlinks, creates the image exclusively relative to the opened directory, and
passes the open file descriptor to `mkfs.ext4`; validation checks the same inode
and rejects path replacement. Systemd mounts the image under the root-owned home
entry, which the runner cannot replace after validation.
If the reviewed checkout is not present at the inventoried path, first verify
that the target is absent and clone the reviewed `main` there. Do not overwrite
an existing checkout.
```bash
sudo test ! -e /opt/akyldash/current
sudo git clone --branch main \
--single-branch https://git.vakanaut.ru/admin/akyldash.git \
/opt/akyldash/current
```
From that root-owned reviewed checkout on the VPS:
```bash
sudo bash /opt/akyldash/current/infra/runner/install-runner.sh
```
Then, as a repository owner, open repository **Settings → Actions → Runners**,
create a repository-level runner, and copy its registration token locally. Do
not send the token in chat or save it in the checkout. Register interactively
in a terminal so it is not placed in shell history or process arguments:
```bash
sudo -u akyldash-runner env HOME=/var/lib/akyldash-runner/.config/act-runner \
/usr/local/bin/act_runner \
--config /etc/akyldash-runner/config.yaml register
```
Enter `https://git.vakanaut.ru/`, the token, runner name `akyldash-ci-vps-01`,
and label `akyldash-ci:docker://docker.gitea.com/runner-images:ubuntu-24.04`
when prompted. The config sets the label and capacity; verify them in the
repository runner settings. The token is saved only in the mode-0700 runner
runtime directory, outside Git. Start/enable the runner after registration:
```bash
sudo systemctl enable --now akyldash-act-runner.service
sudo systemctl enable --now akyldash-runner-healthcheck.timer
sudo systemctl status akyldash-act-runner.service
sudo systemctl status akyldash-runner-healthcheck.timer
```
The interactive command's terminal behavior should be confirmed before pasting
the token. If it echoes input, stop and use the repository settings to revoke
that token; do not put it in command-line arguments or environment variables.
## Validation
1. Confirm the runner is **Online** in the repository runner settings, with the
expected name and `akyldash-ci` label.
2. Confirm **Runner smoke test** runs for the open PR (or dispatch it manually).
It checks that production
sockets, files, environment secrets, and writable application paths are not
present in the job container.
3. Confirm the run reports the expected runner, succeeds, and the runner
service and health timer remain active.
4. Inspect the `CI` workflow run and confirm Caddy plus both no-push Kaniko
actions pass on `akyldash-ci`. Image publication is a distinct manual
main-only workflow with package-only credentials; it does not deploy.
Never add production service secrets, Docker socket access, or host mounts
to this runner.
## Upgrade
Update `runner_version` and its expected official SHA-256 together in
`infra/runner/install-runner.sh`, review upstream release compatibility with
the installed Gitea, and open a PR. Do not replace the binary while a job is
active. Stop the runner, install/verify the new binary, then start it and
confirm Online plus a smoke run. Preserve
`/var/lib/akyldash-runner/.config/act-runner/.runner`.
## Recovery and rollback
The runner is disposable: production data and secrets are not on its filesystem.
For a runner process failure, inspect `systemctl status` and
`journalctl -u akyldash-act-runner.service`; restart the fixed runner service.
If installation stops after creating or enabling the mount unit, inspect the
image and mount with `stat`, `blkid`, `findmnt`, and `losetup`, then rerun the
reviewed installer only when the image is 15 GiB ext4 and the active mount is
backed by it. The installer recognizes that state and does not format or
restart the mounted filesystem. If size, filesystem, unit contents, or backing
device differ, stop and resolve the state manually; never format the existing
image as a retry.
For lost/corrupt registration state, stop the service and obtain a fresh
repository-level token from Settings → Actions → Runners. Preserve the 15 GiB
data image and state for diagnosis; re-register only after the repository
owner confirms/revokes the old runner in Gitea. The images/cache can be rebuilt
from the workflow. Never format or remove an existing runner store as an
automatic recovery step.
## Status
The owner reports that the repository-scoped runner is registered, appears
Idle in Gitea, and its rootless Docker daemon works; the report date was not
recorded. Gitea status checked 2026-10-18 showed smoke run 62 successful and CI
run 61 pending on head `69c62466`. That workflow requested `ubuntu-latest`,
while the smoke workflow used `akyldash-ci`; the label difference is a
plausible explanation for the pending status, but the runner inventory API
returned 403 and the scheduler's reason was not independently confirmed.
On head `3a89dc21`, CI run 63 failed at the host audit root guard and smoke run
64 succeeded. On head `436806a8`, CI run 65 failed in the Caddy container action
with `exec: "validate": executable file not found`; Kaniko was skipped and smoke
run 66 succeeded. On head `d96fc4d3`, CI run 67 failed because the Caddy action
looked for `/github/workspace/deploy/production/Caddyfile`, which was not
mounted at that fixed path; Kaniko was skipped and smoke run 68 succeeded. On
head `5d72bb09`, CI run 69 and smoke run 70 succeeded. CI validated the Caddyfile
and built the backend and OpenSearch images using `${{ github.workspace }}`.
Actual per-action resource-limit enforcement and peak usage were not
independently measured or verified. Image publishing, host-wide resource
metrics, and production deployment remain unverified. Root-guard tests
use `setpriv` for negative cases when jobs start as UID 0 and separately test
the host-audit guard with simulated UID 0 and UID 65534; the real host audit,
production config setup, and runner installer are never executed as root by
those tests. No VPS changes are part of this PR.
+209
View File
@@ -0,0 +1,209 @@
# Corpus inventory and production handoff
The repository contains a read-only verifier for the ignored local corpus at
`data/minjust-cbd/` and `data/minjust-normalized/`. It does not download,
normalize, copy, index, or modify source files. Keep inventory artifacts outside
the repository and both source trees.
## Run the inventory
From the repository root, create a private artifact directory and run:
```bash
artifact_dir=$(mktemp -d /tmp/akyldash-corpus-audit.XXXXXXXX)
python3 infra/deploy/verify_corpus.py \
--raw-root data/minjust-cbd \
--normalized-root data/minjust-normalized \
--output-dir "$artifact_dir"
```
The tool writes `result.json`, `inventory.sqlite3`, and a persistent lock file
into that directory. Keep the directory private: the inventory contains source
paths and checksums. A second run against the same artifact directory is
rejected while a scan is active. Exit
status `0` means the scan and structural checks completed without errors; `1`
means findings were recorded or the run is incomplete; `2` means the arguments
or artifact path were invalid. Read `result.json` to distinguish those states.
Warnings, including documents with zero text fragments, are reported
separately and do not turn the scan into a failure.
For an interrupted run, use the same artifact directory and add `--resume`.
Resume rehashes every source file instead of trusting cached timestamps, then
reports added, changed, and removed paths relative to entries already stored
in the inventory. After resuming an interrupted first pass, those counters are
relative to the partial saved inventory; the full rehash and final checks still
cover every path. A successful result therefore describes the latest complete
pass. Run against a quiescent corpus; do not normalize, fetch, or copy files
into either source tree during the scan. Do not point `--output-dir` inside
either source tree or through a symlink. The verifier rejects such paths before
creating or changing them; it also flags symlinks, reparse points, hard links,
unsupported file types, and files that change while being read.
The inventory database schema is versioned. Resume requires the same root
metadata and verifier SHA-256 recorded in `result.json`; start a new artifact
directory after changing the verifier. `entries` counts files plus directories;
`files` and `directories` are separate counts. Document-prefix checks use
indexed path ranges, and stale-path reporting streams rows from SQLite.
The audit checks SQLite integrity, manifest/document directory agreement,
normalized schema and normalizer versions, source inventory checksums,
fragment identities and text hashes, required output files, and text assembled
from fragments. It does not establish the legal authority, completeness, or
semantic correctness of MinJust content.
## Compare source and staging before transfer
Run a complete inventory of the approved normalized source and the copied
staging tree into separate private artifact directories, using
`--normalized-root` for each. Require `status: complete` and
`verification_status: passed` in both results. The comparison command also
requires each manifest to contain at least one document and each normalized
tree to contain at least one fragment; an empty source or staging tree is a
failure.
```bash
python3 infra/deploy/compare_corpus_inventories.py \
"$source_artifacts/result.json" "$staging_artifacts/result.json"
```
The checker streams every relative path, size, and SHA-256 from both SQLite
inventories, and requires the same verifier/schema version and different
normalized roots. It opens both existing private `.inventory.lock` files
read-only and holds shared nonblocking locks through comparison; a missing,
unsafe, or busy lock fails closed. A zero exit status confirms a non-empty
staging tree matches the source. Keep both trees quiescent during their scans
and comparison. This is an equality check only; the owner still reviews the
code `6` disposition and approves the transfer destination.
## Current data disposition
As measured on 2026-10-18, both local manifests contain 209,958 documents and
pass SQLite `quick_check`. All normalized manifest entries are `success` at
schema `1` and normalizer `1.0.0`. The raw manifest records one fetch error for
code `6`, which is absent from its `documents` table; this does not prove that
the source document is absent. On 2026-10-18, bounded calls through the
repository's rate-limited MinJust client received HTTP `404` for
`GetDocumentListByQuery`, `GetDocument(6)`, control `GetDocument(1096)`, and
`CheckAvailable`. The listing call failed before returning a code list, and
both document calls failed identically. The repeated `404` is consistent with
the OpenData endpoint being unavailable or its route having changed, but does
not identify which. It is not evidence that code `6` is absent. The [public
document listing page](https://cbd.minjust.gov.kg/list-docs/kg) was reachable,
but it does not confirm this numeric API code. Do not describe the corpus as
fully verified or silently waive this error. Recheck the official listing and
API when the endpoint responds, then record an explicit owner disposition.
From the VPS, a metadata-only HTTPS check to `CheckAvailable` failed at the
HTTP/2 transport layer (`curl` exit 92, `INTERNAL_ERROR`, HTTP status `000`,
TLS verification `0`). An HTTP/1.1 retry was not completed.
## Current API and bounded code 6 recovery (2026-10-20)
The current official site uses `POST /api/v1/GetDocuments` (page size 12),
`GET /api/v1/GetDocument?DocumentCode=...`, and
`GET /api/v1/GetEdition?editionId=...&lang=ru|kg`. A bounded first-page probe
reported 210,639 records; this recovery does not enumerate the full list. The
official alias `1-4` resolves to canonical code `205064`. Code `6` is present
and has 201 editions. The edition order in `GetDocument` is authoritative;
numeric IDs do not define currentness.
For `lang=kg`, the API returns Kyrgyz HTML in `contentRu` and `contentKg=null`.
The bounded adapter maps that response by the requested language to archive
`ky`, preserves the response and checkpoints, limits requests to one per
second, caps JSON responses at 16 MiB, and caps one document's text at 4 GiB.
It writes only to a separate generation under `/tmp`; it neither resumes nor
overwrites the existing ignored corpus.
Without `exact=true`, a request for `920292` returned response field
`id=920492` despite different text length/hash. Two exact probes for `920292`
and `920492` returned matching IDs and names/dates (`04.08.1998` and
`13.04.2017`) matching `GetDocument`; their RU text lengths/hashes were
485,487 / `2b51c2ff4e9b91cbe2a7ed052a5a9ed9150bda993fc15c120ac948580926b718`
and 1,139,908 / `e5f0fdbb47766e5a5dbb971183d765e4c1dcaf9c969a218a84206244ce9132e8`.
The adapter now requires `exact=true`, returned-ID equality, and matching RU/KY
names before a language checkpoint can complete. Its versioned request
contract is part of both staging metadata and the SQLite resume identity;
pre-contract generations cannot be resumed.
The adapter does not independently validate attachments or embedded media.
Even after the text stage completes, the raw manifest records this as an
unresolved error. Do not merge this single document into the existing corpus,
claim corpus completeness, or transfer it to production without a separate
review and comparison. The prior no-exact pass at
`/tmp/akyldash-current-code6.ZAMC37u4ex` is marked quarantined in
`qualification.json`; its 240 text checkpoints and 304,493,520 bytes are
retained as unverified, with no document directory published. Its preserved
work-tree SHA-256 is
`1e874c1d7fab1e00e7e45ff1b16422ea474e22ee13c901ac9a0612f47aa5d24a`. It must
not be reused or normalized.
The exact-only generation at `/tmp/akyldash-current-code6-exact.L4k3AvnOlP`
contains 201 editions and 402 complete RU/KY text checkpoints (604,377,885
raw bytes). The raw manifest passes `quick_check`; its stored source SHA-256
matches the staged document tree at
`81d8e1a53b2ebef61f08b56d04b3f0a80077d587a4fadae0152bb4b7f13055ea`. Separate
normalization at `/tmp/akyldash-current-code6-exact.L4k3AvnOlP-normalized`
passes `quick_check`, records normalizer `1.1.0`, and preserves first/last
edition order `920292` (`04.08.1998`) to `920492` (`13.04.2017`). The normalized
generation is about 2.05 GB. Neither temporary generation has been merged into
the existing corpus. Attachments and embedded media remain unverified.
The first full pass (run `b4a4556e30cc464d90bc8d75ef1673e9`) stopped before
validation with `Python int too large to convert to SQLite INTEGER` after
2,120,420 raw entries and 397,856 normalized entries. This was an inventory
metadata storage failure, not a corpus finding. The verifier now stores file
identity values as text and records its own SHA-256. A complete rehash with the
updated verifier is required; do not treat the failed pass or progress output
as a completed audit.
A fresh full rehash is now running in `/tmp/akyldash-corpus-audit-final.gtsD87vc`,
run `f4dd5e53020f442bbbecee2d0e4986a6`, verifier SHA-256
`83dac230099fc91972cd14e86e9f8ec72d13bca08f53d402f13a169e096d98f5`. Check
its private `result.json` for final counters, findings, and manifest comparison.
## Production transfer and indexing gates
Production deployment is not enabled. Before any transfer, the owner must
review the root-only host audit, confirm the target data location is empty or
backed up, resolve the raw code `6` finding, and approve the exact normalized
corpus to use. Transfer only `minjust-normalized/`; keep the raw source archive
offline. Do not preserve the source's Windows/WSL file permission bits. Copy
to a new staging location with owner-approved permissions, verify the copied
manifest and files there, and only then consider publishing it under
`DATA_ROOT/minjust-normalized`.
### Atomic publication gate for `DATA_ROOT`
The verified staging tree must be published to
`DATA_ROOT/minjust-normalized` as one all-or-nothing operation, using an
owner-approved atomic mechanism or a separately documented equivalent. An
in-place copy into the live target is not an acceptable publication method.
Preserve the current published corpus until the candidate is fully verified and
ready for the atomic switch; never overwrite its contents in place.
The publication mechanism and its interrupted-operation recovery have not yet
been specified or tested; this is a release gate, not a claim that atomic corpus
publication is implemented. Until that gate is completed, do not publish or
replace a corpus in `DATA_ROOT`.
If preparation is interrupted before publication, keep the current target
untouched. Resume only from the isolated staging tree after a full rehash and
source comparison; if that cannot be established, fail closed and prepare a
new staging candidate. If interruption makes the outcome of the final
publication ambiguous, do not retry or replace anything automatically. Verify
the target against the approved manifest and resolve the state with the owner
before any retry. Never overwrite an existing corpus or OpenSearch volume.
The atomic filesystem publication of the approved corpus into `DATA_ROOT` and
the OpenSearch alias switch are separate gates. A successful corpus publish
does not authorize indexing or alias changes; do not conflate the two.
The bulk importer now refuses alias publication until all Bulk item results
are successful, the full staged index is refreshed, its fragment count exactly
matches the complete normalized inventory (including on resume), and approved
Russian and Kyrgyz canaries both return text. The alias must currently target
the exact `--expected-old-index`; wildcard removals and implicit first-time
alias creation are rejected. OpenSearch performs removal of that exact target
and addition of the candidate in one alias request. A mismatch or validation
failure leaves the existing alias untouched. This is a code-level gate only:
no production index or corpus was changed, and the live inventory and canary
terms still require owner verification. See `backend/README.md` for the
operator flags and use a new versioned index for every candidate.
+477
View File
@@ -0,0 +1,477 @@
# Infrastructure current state
## Windows ACL SID resolution — 2026-11-03
- Read-only PowerShell 5.1 inspection found that display-name conversion of
ACL entries can throw `IdentityNotMappedException` even though the ACL itself
is readable. The installer and external-receiver guards now enumerate access
rules directly as `SecurityIdentifier` values and read owners as SIDs; unknown
principals remain visible to the existing fail-closed policy. Regression
coverage exercises synthetic unmapped SIDs through the actual installer guard
functions. These repository changes are pending review; no Windows ACL,
installer, task, VPS, or production state was changed. The native PowerShell
harness still requires execution on Windows before this behavior is confirmed
in that runtime.
## External deployment runtime binding — 2026-11-03
- PR #59 is merged to `main`; it contains the root-only read-only status
endpoint and exact-literal SSH dispatcher for `status` and `deploy`. The
status path bounds the Gitea run list to ten entries, verifies the selected
run/workflow/current `main`,
validates artifact metadata and ZIP bytes, and returns only a release tuple.
This unmerged change adds a two-SHA compatibility gate: the fixed root helper
compares protected Compose/Caddy bytes and installed release policy with
Gitea content pinned to the release SHA, verifying each Git blob hash and
size. This permits application-only release B after bootstrap A without
advancing the root checkout or executing new root code. A runtime config or
policy change fails closed and requires a reviewed bootstrap update. The
Windows response state is `prepared`, never `candidate`; the `--once` task
stores a protected local tuple and cannot call deploy. A separate disabled
auto-apply task and config gate require the tuple to match a fresh status
response from a previous poll. Status and apply retain current-main,
successful CI/release provenance, descriptor digest, external backup,
cutover, canary, and rollback checks. The receiver repeats the config check
before journaling/stopping services and again after backup immediately before
switching refs.
- Windows client предназначен для подготовки и auto-apply; обе задачи по умолчанию
выключены, целевой путь фиксирован: `D:\Dev\akyldash`, SSH alias —
`akyldash_release`. Примеры config и Task Scheduler XML также выключены; задачи
запускаются от интерактивного пользователя с минимальными правами, не допускают
параллельных запусков и не будят компьютер. Windows task, SSH key, VPS command,
helper и trigger не устанавливались и не запускались. Путь canonical
`C:\ProgramData\Akyldash\release-trigger.json` известен, но содержимое и
enabled-состояние не читались. Метаданные Windows подтверждают
`C:\Python313\python.exe`, действительную Authenticode-подпись Python Software
Foundation и защищённые ACL интерпретатора.
- Linux fixtures прошли для Python import и command-path poisoning, включая очистку
унаследованных `GIT_*` и фиксацию путей `ssh.exe`/`git.exe`/`powershell.exe`.
Installer получает ограниченный bundle из точной вершины `main`: упаковщик
читает Git objects commit, tree и allowlist-файлов; elevated installer проверяет
SHA-256 ZIP и цепочку `commit → tree → файлы` из одного read-locked потока до
записи и не запускает Git. ACL проверяются явными масками мутаций и подмены;
текущая учётная запись не может владеть кодовыми путями, а право создать
соседнюю папку не считается подменой предка. Windows ACL/install проверки пока
только прошли код-ревью; installer и launcher на Windows не запускались.
Production CD остаётся выключенным.
- Remaining owner prerequisites are installation of the reviewed root helper,
exact forced-command/SSH host-key setup, a protected Windows client and its
private state/config ACLs, private D: backup ACLs, and all previously listed
backup/restore and production readiness gates. Neither the status endpoint nor client advances
the host checkout or updates root helper code.
- Read-only Gitea check for fresh `main` SHA
`f19faf628e67015febac22d0ec93ce4782caf4bf`: CI run #706 succeeded; release
run #708 failed at the explicit registry credential-presence check because
`REGISTRY_USERNAME` and `REGISTRY_PASSWORD` are absent; handoff run #709 was
skipped. No secret values were read. Image publication remains owner-blocked.
Owner decision recorded 2026-10-26: a complete backup must not be stored on
the VPS, including temporarily. The local archive/full cold-backup commands in
`BACKUP.md` are superseded and unauthorized. PR #59 now contains a proposed
Windows-controlled external stream receiver and root-side deploy/rollback
code. The Windows rerun passed 12 synthetic client checks and 8 protocol
checks, plus a disposable real-NTFS fixture for the private owner ACL. The
production D: path, SSH connection, VPS installation, full backup, and
separate-target restore remain unverified. No verified external backup or
separate-target restore exists, so production deployment remains blocked by
the backup gate.
## Code-only deployment work — 2026-10-31
- Work is being prepared in the existing PR #59 branch from the then-current
`main`; no merge, VPS operation, live release, or workflow dispatch was
performed for this work.
- The release descriptor now binds both the release run and its successful CI
source run. Host-side source verification uses the installed `gitea-api`
helper contract; the PR carries the inspected helper fixture plus a bounded
binary-download patch. The installer verifies the fixture hash before
applying the patch.
- Backup bytes are streamed through a bounded SSH protocol to the fixed
Windows destination. Root release state stores only a secret-free backup
identity. The rollback handshake requires the exact previously pinned
archive and creates a fresh external backup before image references change.
Local VPS archive paths fail closed.
- Review fixes in this PR require Gitea `status=completed` with
`conclusion=success`, resolve `main` from `/opt/akyldash/current`, write a
durable pending journal before stopping services, and reject an already
applied release before downtime. The Windows client checks D: capacity,
NTFS/path/ACL owner and allowed entries, and the local ignore rule before
SSH; it requires 100 GiB plus 1 MiB free. The Windows rerun passed 12
synthetic client checks, 8 protocol checks, and a disposable real-NTFS
private owner-ACL fixture; actual D:, SSH, and VPS remain unverified.
- Receiver cleanup after a post-publication ACL failure is identity-bound to
the owned staging directory and permits only the expected archive files. A
replaced, reparse, or unexpected target is left for manual review without an
ACK; this path is covered by local fixtures and the Windows rerun.
- Synthetic tests cover archive/protocol integrity, helper download rules,
artifact/run provenance, receiver publication, release schema, and rollback
refusal. The 12 synthetic Windows client checks, 8 protocol checks, and
disposable real-NTFS private owner-ACL fixture passed. The actual D: path,
SSH alias, Gitea helper against
the live instance, root helper installation, artifact redirect, production
backup/restore, and actual release/rollback remain unverified.
- Owner bootstrap blockers remain: verify a root-controlled SSH route for the
fixed commands using existing host-key checking; provision only the
necessary read-only Gitea token, exact redirect origin if needed, and
approved RU/KY canaries; confirm registry pull and retention policy; test
the Windows client and a separate-target restore. No new key, sudo rule,
Docker access, credential, or VPS setting is added by the code change.
Read-only deployment recheck: 2026-10-25. Source: SSH as `codex`, direct-origin
and public HTTPS probes, local `main` checkout, and Gitea API. No VPS files,
services, firewall, containers, volumes, production data, or credentials were
changed or inspected. Root-only Docker state, UFW rule details, protected file
metadata, current backup/restore validity, and the live corpus/index remain
unverified. The 2026-10-18 inventory below is a historical snapshot where it
conflicts with this dated recheck.
## Latest read-only recheck — 2026-10-25
- `https://akyldash.kg/review` returned HTTP 200 with valid TLS through the
public Cloudflare edge and through direct HTTPS to origin `206.223.249.118`.
This verifies that route only; it does not prove search or index readiness.
- TCP listeners now include 22, 80, and 443. The older no-listener/Cloudflare
521 observations below are historical, not current state.
- VPS reports 4 CPUs, 7.7 GiB RAM (about 3.7 GiB available), and 31 GiB free
on `/`. Current runner-mount free space was not rechecked.
- `/opt/akyldash/current` exists, is a root-owned directory mode `0755`.
Reading its Git SHA failed with Git's `dubious ownership` safety guard; no
`safe.directory` setting was added, so the installed revision is unknown.
- `akyldash-act-runner.service` is active under `akyldash-runner`; installed
`act_runner` is `0.2.13`. The separate `akyldash-runner-restart.service`
remains failed since 2026-10-14; its cause could not be read from the
unprivileged journal view. No restart was attempted.
- `akyldash-verify`, `akyldash-compose`, and `akyldash-rollback` are absent
from PATH in the unprivileged check; no root helper or Docker operation was
run.
- Gitea is `1.25.5`; `main` is `b5adc09203cbbbd298054248206b9f47ae4a44ca`.
CI run 175 and runner smoke 176 succeeded on that commit. Package write
access and runner inventory remain outside this API principal's permissions.
- PR #59 head `852d639c455ff1c60202405e112d47b8128d66c6` received formal
review #308 with five requested fixes. CI run 177 failed in
`test_digest_rollback_fails_before_stopping_or_changing_references` because
the test's global `Path.stat` mock made `Path.exists()` report a false
recovery-journal hit under CI Python 3.12; runner smoke 178 succeeded. A
fix batch in this PR isolates state paths, handles ambiguous alias/cutover
outcomes, verifies rollback image IDs, and adds a synthetic artifact upload
check. On 2026-10-25 CI run 179 and runner smoke 180 succeeded at PR head
`9d8ce6b6`; CI took 2m43s and the synthetic upload step reported success.
The run API identifies run ID 567 and reports `run_attempt: 0`, Gitea's
legacy sentinel. The release workflow therefore uses the unique run ID and
does not depend on the unavailable attempt value. The run's artifact link was
downloaded from Gitea UI as `artifact-smoke-567-`: the ZIP was 191 bytes,
SHA-256 `ae0a2262147f222aacb4fdd1d12459f7160c67b1d0ee5d7ae2db1de8958f120f`,
and contained the expected 51-byte `marker.txt`. The API artifact listing
available to this principal returned no entries, but the downloaded file
verifies artifact availability. No live release or VPS change was made.
- The release descriptor SHA-256 sidecar now records `release-manifest.json`
relative to the `release` directory, matching the flattened layout observed
for downloaded Gitea artifacts. A local synthetic regression verifies the
checksum from the artifact extraction root; the credentialed release
workflow remains unrun.
- Owner-only outstanding evidence: sanitized container/volume and protected
file metadata; current backup and separate-target restore verification;
live corpus/index counts and RU/KY canaries; Cloudflare TLS mode; registry
writer identity and digest retention. Never include secret values in that
evidence packet. The checklist is in
[`DEPLOYMENT_OWNER_PACKET.md`](DEPLOYMENT_OWNER_PACKET.md).
The deployment PR adds a manual main-only image-publish workflow and an
owner-run `--no-build` cutover path, but neither has been executed against the
VPS. Its release descriptor is an unsigned trace record; cryptographic image
attestation remains a separate owner decision.
## Historical host snapshot — 2026-10-18
Source: SSH as `codex`, local repository inspection, and Gitea API. Root-only
Docker state, UFW rule details, and protected file metadata were not verified.
## Historical VPS facts — 2026-10-18
- Host: `avprogs.hlab.kz`, `206.223.249.118`, SSH port `22`, user `codex`.
- OS: Ubuntu Server `24.04.5 LTS`; kernel `6.8.0-142-generic` from the prior
inventory audit.
- Resources: 4 vCPU, 7.7 GiB RAM, 3.8 GiB swap, 99 GiB root filesystem with
84 GiB free on 2026-10-18. Runner storage is a separate 15 GiB ext4 loop
mount with 12 GiB free at that check.
- Network: interface `ens3`, IPv4 `206.223.249.118/32`.
- `codex` is UID/GID 1000, groups `codex` and `users`; `sudo -n true` requires
a password. Docker CLI cannot access `/var/run/docker.sock` as `codex`.
- SSH service is running. The inspected sshd configuration has public-key and
password authentication enabled and `PermitRootLogin yes`; no SSH changes
were made.
- Bootstrap completed. Docker CLI `29.9.0` and Compose `5.6.0` were rechecked
over SSH on 2026-10-18; containerd `2.4.1` and runc `1.5.2` are from the
prior package audit. The Docker daemon API remains root-only.
- `/etc/docker/daemon.json` has the expected `local` log driver and `10m` / `3`
rotation limits. Bootstrap paths exist with root ownership: `/opt/akyldash`
mode `0755`, `/etc/akyldash` and `/var/backups/akyldash` mode `0700`, and
`/var/lib/akyldash/data` mode `0750`.
- `/opt/akyldash/current`, verifier/rollback binaries, and the sudoers file are
absent. `codex` has not received sudo or Docker-group access. Metadata for
`/etc/akyldash/production.env` and `/etc/akyldash/review_secret` could not be
checked because access is denied. The target
`/var/lib/akyldash/data/minjust-normalized` and its manifest metadata were
also denied; their presence, ownership, and contents are unknown. No sudo was
used.
- TCP listeners observed: SSH `22` and loopback DNS only; no listener on `1500`,
`80`, or `443`. No ISPmanager package or service unit was found through the
unprivileged checks; root can confirm with the audit script below.
- Owner reports UFW is active and allows TCP `22`, `80`, `443`, and `1500`.
Rule details were not independently read; no firewall changes were made.
- Additional read-only runner checks on 2026-10-12 confirmed `/` is ext4 with
about 92 GiB free at the prior audit, `/usr/bin/dockerd-rootless.sh` exists,
and `git.vakanaut.ru` resolves to `195.209.48.28`. From the VPS,
`https://git.vakanaut.ru/api/v1/version` returned Gitea `1.25.5`; runner
registration and Gitea Actions protocol connectivity are not confirmed.
- `akyldash.kg` resolved to Cloudflare IPv4/IPv6 edge addresses. HTTPS returned
Cloudflare `521` on this historical 2026-10-18 check; current recheck is
recorded above. Cloudflare Full (strict) mode remains unverified.
## Historical application and Gitea facts — 2026-10-18
- Repository: `admin/akyldash`, default branch `main`; Gitea version `1.25.5`.
- The repository API reports push permission, Actions and packages enabled, and
PRs enabled. `main` is protected and requires one approval; the current API
principal cannot push or merge directly to `main`.
- The repository runner-list API returns `403` because this API principal is
not the repository owner. The latest verified CI run 75 and smoke run 76 both
succeeded on `main` head `0e276ae6`. The Actions run-list endpoint returned
`403` for this API principal during this recheck, so no newer run state was
independently retrieved. The owner reports the runner is registered and
Idle; repository runner inventory and package publishing permissions remain
unverified.
- No public application listener is observed on TCP `80`/`443`. Docker
container/volume state still requires the root audit. Repository Compose describes
Caddy `2.11.6` pinned by digest, Python Search API `0.12.2` in this branch,
SQLite account/review data, and OpenSearch `3.7.0` with `analysis-icu`.
- The target publishes only Caddy on TCP `80`/`443`. Backend and Caddy share a
proxy network; OpenSearch and backend share a separate internal network, and
backend has no host port. Caddy certificate storage, OpenSearch, and
application data use persistent volumes/bind mounts.
- The root-owned repository checkout, production env/secret files, Compose
volumes/containers, backup, verifier/rollback helpers, and application are
not confirmed installed. TLS and registry digest deployment for
backend/OpenSearch are not configured.
- The repository has a cold-backup archive/verification tool and a one-shot
restore apply tool that requires a marked replacement host. Backup/restore
tests use synthetic data; neither tool has run against production. The
archive preflight permits a clean first deployment to skip backup, requires
backup for existing persistent data even without containers, allows absent
named volumes only in explicit corpus-only initial-deployment mode, and
rejects inaccessible/ambiguous mounts and incomplete/running service
containers. Initial deployment docs require a second verified backup after
index and admin initialization.
- Recovery bootstrap has an explicit `--recovery-target` mode that refuses the
inventoried production hostname/IP; its host-identity guard has shell tests.
The mode has not been run on a VPS.
- The repository defines a root-owned read-only verifier, Compose `config` and
`status` helper, guarded rollback helper, and installer with exact sudoers
command rules. None was installed on the VPS at this inventory. The current
PR adds fixed root-side release/rollback receivers without adding a `codex`
sudoers rule or Docker access. Neither receiver has been installed or run.
- The former local-VPS rollback path has been superseded and now fails closed.
The PR rollback path requires a root-pinned external archive identity,
rechecks its exact bytes through the owner client, creates a fresh external
backup before changing refs, and preserves a secret-free recovery journal on
failure. Synthetic tests only; no production rollback was performed.
- The image release workflow now starts after successful CI for a push to
`main`. It checks the source workflow/event/branch/conclusion and exact source
SHA, checks out that SHA, and uses both release and source run IDs in the
schema-v4 descriptor. Registry secrets `REGISTRY_USERNAME` and
`REGISTRY_PASSWORD` remain unconfigured/unverified; no values were created or
inspected. PR image builds remain no-push. The descriptor SHA-256 is included
in the artifact name, and the artifact also retains its checksum sidecar.
The descriptor remains unsigned trace metadata, not cryptographic
attestation.
- A separate `deploy-handoff.yaml` runs after a successful release and checks
the source run and stale-main guards. Its job is explicitly
`NOT DEPLOYED - handoff status only`; a green result is not a deployment. It
reads artifact metadata through `gitea-api` only if that helper is available
in the job image; otherwise it reports metadata and host handoff as
unverified. It does not download artifact ZIPs, connect to SSH, or apply a
release. No repository workflow binding to a Windows runtime starts the
receiver. PR #59 now proposes a separate disabled local `--once` trigger
that queries a root-verified tuple; it is not installed or active. Image
release is automated, but production deployment is not yet automatic CD. The original
audited `gitea-api` helper lacks binary output and safe 302 handling; the
current PR carries a tested patch and host-side artifact verifier, but
neither is installed. No deploy key, fixed SSH entry, extra sudo rule, or
Gitea CD variable has been configured. Production apply remains blocked on
owner bootstrap, external backup and separate-target restore, and the other
gates above.
- A repository-scoped official `act_runner` `0.2.13` installation is
configured in `infra/runner/`: rootless Docker under `akyldash-runner`, 15 GiB
ext4 loop-backed image/workspace storage, capacity 1, per-job limits (2 CPU,
3 GiB RAM, 512 processes), account slice limits (2.5 CPU, 4 GiB RAM, 2048
tasks), rotated file logs, and a systemd health timer. Jobs target the
`akyldash-ci` label and do not receive either Docker socket. Latest verified
main CI run 175 and smoke run 176 succeeded on `b5adc092`. On 2026-10-18,
SSH showed `akyldash-act-runner.service` active under `akyldash-runner`, with
zero restarts and about 115 MiB current memory. Its rootless Docker store is
mounted from `/dev/loop0` as ext4 with `rw,nosuid,nodev`; the 15 GiB mount had
12 GiB free. The separate `akyldash-runner-restart.service` remains failed
with exit status 1 since 2026-10-14; its journal had no entries, so the cause
is unknown. The latest verified main CI run 75 and smoke run 76 succeeded on
head `0e276ae6`. The Actions API returned 403 during this recheck, so no newer
run state was independently retrieved. A read-only SSH recheck on 2026-10-21
confirmed the active `akyldash-act-runner.service` runs as
`akyldash-runner` with `ProtectHome=read-only`; `ReadWritePaths` includes the
runner runtime/config/log paths and `/run/user/999`. Its `ExecStart` uses
`/etc/akyldash-runner/config.yaml`. The live unit retains a writable carveout
for `/run/user/999`; the installer keeps that existing carveout and changes
`ProtectHome` from `yes` to `read-only` so the runtime path remains visible.
No VPS unit was changed. Main commit
`d2fb4b81` passed CI run 115 and smoke run 116. On
head `3a89dc21`, CI run 63 failed at the host audit root guard and smoke run 64
succeeded. On head `436806a8`, CI run 65 failed in the Caddy container action
with `exec: "validate": executable file not found`; Kaniko was skipped and
smoke run 66 succeeded. On head `d96fc4d3`, CI run 67 failed because the
Caddy action's fixed `/github/workspace/...` path did not exist; Kaniko was
skipped and smoke run 68 succeeded. On head `5d72bb09`, CI run 69 and smoke
run 70 succeeded. CI validated the Caddyfile and built the backend and
OpenSearch images using `${{ github.workspace }}`. Actual per-action
resource-limit enforcement and peak usage were not independently measured or
verified. Image publishing, host-wide resource metrics, and production
deployment remain unverified. No VPS changes are made.
- An isolated local Compose smoke test built backend/OpenSearch images,
reached healthy status for OpenSearch, backend, and Caddy, and returned HTTP
200 for `/review` through Caddy. Its containers, networks, and volumes were
removed afterward. This does not verify VPS startup, DNS, or public TLS.
- The app has no migration framework or SMTP setup. Repository versions on this
branch are:
backend `0.13.0`, frontend `0.2.0`, Telegram bot `0.2.2` (the bot is not
deployed on this VPS).
## Corpus readiness (2026-10-18)
- The local raw archive and normalized corpus are ignored under `data/`; no
corpus files were changed. Their read-only SQLite manifests both pass
`quick_check` and each report 209,958 documents. The raw manifest is
28,295,168 bytes with SHA-256
`f8e3dd2fdec607583ca09cfdb5b76171bb7bfa1e41a0e07ee5aca8481e18e17e`; it has
one fetch error, code `6`, absent from `documents`. The normalized manifest
is 31,571,968 bytes with SHA-256
`1679c20e6d0f3e5a9b50737c006c4417a28ebcaf7467d194d3bf8243538f83bf`; all
entries are `success`, schema `1`, normalizer `1.0.0`.
- The read-only full-file inventory in `/tmp/akyldash-corpus-audit.Mjr6aa`
stopped before validation in run `b4a4556e30cc464d90bc8d75ef1673e9` with
`Python int too large to convert to SQLite INTEGER`. At stop it had processed
2,120,420 raw entries (1,081,428 files, 1,038,992 directories) and 397,856
normalized entries (246,321 files, 151,535 directories). This is a verifier
metadata storage failure, not a corpus finding. A full rehash with the
corrected, versioned verifier is required before readiness can be stated.
- A fresh full rehash is running in `/tmp/akyldash-corpus-audit-final.gtsD87vc`,
run `f4dd5e53020f442bbbecee2d0e4986a6`, with verifier SHA-256
`83dac230099fc91972cd14e86e9f8ec72d13bca08f53d402f13a169e096d98f5`.
`result.json` currently reports `scan_status: running`; its live counters
must be recorded after completion. Sources remain read-only.
- Through the repository client, bounded MinJust OpenData requests for
`GetDocumentListByQuery`, `GetDocument(6)`, control `GetDocument(1096)`, and
`CheckAvailable` each returned HTTP `404`. The list API returned no code list;
the direct requests do not establish whether code `6` exists. The public
listing page is reachable, but it does not map the numeric API code. A
metadata-only `CheckAvailable` request from the VPS failed in HTTP/2 with
`curl` exit 92 (`INTERNAL_ERROR`), HTTP status `000`, and TLS verification 0;
the HTTP/1.1 retry was not completed. Treat the code `6` disposition and
full corpus readiness as unresolved.
- On the VPS, read-only `stat` of `/var/lib/akyldash/data/minjust-normalized`
and its manifest was denied. Production corpus presence and metadata remain
unknown. No corpus was transferred and no production service, Docker socket,
runner policy, or VPS configuration was changed.
## Current MinJust API staging (2026-10-20)
- The public site's current API uses `POST /api/v1/GetDocuments` with page size
12, `GET /api/v1/GetDocument?DocumentCode=...`, and
`GET /api/v1/GetEdition?editionId=...&lang=ru|kg`. A bounded page-1 probe
reported 210,639 filtered records; no full listing was enumerated for this
recovery. Official alias `1-4` resolves to canonical document code `205064`.
- Document code `6` has 201 official editions. The API's `lang=kg` response
places Kyrgyz HTML in `contentRu` while returning `contentKg=null`; staging
maps by requested language (`kg` to archive `ky`) and preserves the edition
order from the document response. Normalization and search export now retain
and use that order instead of sorting IDs.
- Without `exact=true`, one probe for edition `920292` returned response field
`id=920492` despite a different text length/hash. Two bounded `exact=true`
probes returned matching IDs and names/dates from `GetDocument`: edition
`920292` / `04.08.1998` (485,487 RU characters, SHA-256
`2b51c2ff4e9b91cbe2a7ed052a5a9ed9150bda993fc15c120ac948580926b718`) and
edition `920492` / `13.04.2017` (1,139,908 RU characters, SHA-256
`e5f0fdbb47766e5a5dbb971183d765e4c1dcaf9c969a218a84206244ce9132e8`).
Every edition request now uses `exact=true`, requires returned ID equality,
and checks RU/KY names against `GetDocument` metadata.
- `backend/ingestion/minjust_current.py` stages one document into a separate
generation, at no more than one request per second, with a 16 MiB response
limit, a 4 GiB text budget per document, contract-bound checkpoints, and
atomic publication. Staging requires a private 0700 directory owned by the
current user and rejects symlinks under checkpoints/document paths; legacy
corpus exclusions are anchored to the repository root. The corpus verifier
continues to validate unchanged normalized manifests from versions 1.0.0
and 1.1.0. The earlier non-exact pass is quarantined at
`/tmp/akyldash-current-code6.ZAMC37u4ex/qualification.json`: its 240 language
responses (304,493,520 bytes) remain unverified and are not reusable. The
retained work-tree SHA-256 is
`1e874c1d7fab1e00e7e45ff1b16422ea474e22ee13c901ac9a0612f47aa5d24a`.
- Exact-only code-6 staging is complete at
`/tmp/akyldash-current-code6-exact.L4k3AvnOlP`: 201 editions and 402
complete language checkpoints, 604,377,885 raw bytes. The raw manifest
passes `quick_check`; its document checksum matches the staged directory at
`81d8e1a53b2ebef61f08b56d04b3f0a80077d587a4fadae0152bb4b7f13055ea`.
Separate normalization at
`/tmp/akyldash-current-code6-exact.L4k3AvnOlP-normalized` contains 201
editions in official response order, passes `quick_check`, and records
normalizer `1.1.0` (2,048,797,024 bytes). The prior `data/minjust-cbd` and
`data/minjust-normalized` trees and production were not used as input or
modified. The raw staging manifest still records attachments and embedded
media as unverified; this single document does not establish corpus
completeness or production readiness.
## Checks not completed
- Docker Engine version from the server API, container and volume inventory,
protected env/secret metadata, sudoers, and detailed UFW rules require root.
`infra/bootstrap/audit-host.sh` is provided for an owner-run read-only audit.
- The backup archive/restore tool was tested with synthetic local data; a backup
and restore of real production data remain unverified because production is
not deployed.
- Cloudflare edge response was checked; origin DNS target, origin certificate,
Full (strict), registry publishing, host-wide runner metrics, and production
deployment have not been verified. The
owner-reported Idle state has not been independently checked through runner
inventory because the API returned 403.
- This audit made no host changes. No package or service was installed by this
audit; the owner performed the initial bootstrap beforehand.
## Next owner actions
1. Install the approved root-owned checkout after its PR is reviewed, then run
`sudo bash /opt/akyldash/current/infra/bootstrap/audit-host.sh` and share
the output. The script prints no secret values and does not mutate the host.
2. After reviewing the audit output and confirming the target files are absent,
run
`sudo bash /opt/akyldash/current/infra/deploy/prepare-production-config.sh`;
it refuses to overwrite existing files. Set the approved `DATA_ROOT` and
place the approved corpus/index and Cloudflare origin configuration. Do not
deploy until data/service state and the required backup path are reviewed.
3. Run `sudo bash /opt/akyldash/current/infra/deploy/install-helper.sh` after
reviewing the checkout; it grants only fixed verification, Compose
`config`/`status`, and guarded rollback commands.
4. After merging the reviewed PR, confirm a subsequent CI and smoke run on
`akyldash-ci`. Image publishing and production deployment remain manual and
are not part of CI.
5. Test backup/restore and rollback only with synthetic data in an isolated
environment before any production deployment.
## Change in this PR
Adds a read-only, resumable full-file corpus inventory and normalized-data
cross-check, a source/staging comparator requiring nonzero documents and
fragments plus held shared artifact locks, synthetic regression tests, and an
operator handoff runbook. CI's
existing `infra/deploy` unittest discovery includes the new tests. No workflow,
runner, VPS, production data, or Docker settings are changed. The first real
corpus scan stopped on an integer-storage bug; full validation with the
corrected verifier is still required. The source API's code `6` outcome remains
unconfirmed.
+10
View File
@@ -0,0 +1,10 @@
# Infrastructure decisions
| Date | Decision | Reason and alternatives | Status / limits |
|---|---|---|---|
| 2026-10-11 | Use Docker Compose on the dedicated Ubuntu VPS | Matches the existing production Compose baseline and workload; Kubernetes adds no demonstrated need | Planned; Docker is not installed |
| 2026-10-11 | Keep `codex` out of sudo and the Docker group | Docker socket access is effectively root; a separate installer can grant only a fixed read-only verifier and root-owned rollback helper after checkout ownership and sudoers validation | Helpers are defined in Git; not installed on VPS |
| 2026-10-11 | Put Caddy and Search API on a proxy network and OpenSearch on a separate internal network | Keeps OpenSearch unreachable from the proxy and host; the API has no published host port | Repository Compose target only; not deployed |
| 2026-10-11 | Use Caddy in Compose for HTTPS at `akyldash.kg` | Automatic certificate issuance/renewal keeps proxy config small; Nginx with separately managed certificates was considered | Caddy image pinned by digest; requires public ports 80/443 and owner confirmation of Cloudflare Full (strict) |
| 2026-10-11 | Mount the review signing key as a Compose secret file | Avoids putting its value in command-line arguments; an environment variable or CLI value is easier to inspect in process metadata | Implemented in repository Compose; not deployed |
| 2026-10-11 | Do not enable production deployment from Gitea Actions yet | Runner, package publishing scope, deploy-helper, backup/restore, and production health are not verified | Revisit after controlled end-to-end test |
+237
View File
@@ -0,0 +1,237 @@
# Deployment
## Status and authorization
The release and fixed-receiver code from PR #59 is on `main`, but has not been
installed or exercised against the VPS. This PR's compatibility and Windows
bootstrap changes are still proposed. No release, backup, restore, or
production service operation has been performed by this change. Production
deployment remains blocked until the owner has verified host state, registry
access, protected configuration,
corpus/index readiness, RU/KY canaries, an external backup, and restore on a
separate target. See [current state](CURRENT_STATE.md) and the
[owner evidence packet](DEPLOYMENT_OWNER_PACKET.md).
The existing `codex` account has no Docker socket access and receives no new
privilege in this change. The external receiver requires an owner-controlled
SSH entry that runs the fixed root command; no SSH key, `authorized_keys`
entry, sudo rule, or root alias is created here. If `akyldash_vps` logs in as
`codex`, it cannot run the root-only receiver and the client fails closed.
Do not grant general sudo or Docker access to make the flow work.
## Release verification and image publication
`.gitea/workflows/ci.yaml` remains the required test and image-build workflow.
The release workflow runs after successful main CI, verifies the source run and
commit, and publishes digest-pinned images plus a small release descriptor.
The descriptor records both the release workflow run ID and the successful CI
source run ID. PR builds do not publish images. Production runtime secrets are
not supplied to CI.
Read-only Gitea check on 2026-11-03 for current `main` SHA
`f19faf628e67015febac22d0ec93ce4782caf4bf`: CI run #706 succeeded, release
run #708 failed at the explicit registry-credential-presence check because
`REGISTRY_USERNAME` and `REGISTRY_PASSWORD` are not configured, and handoff run
#709 was skipped. No secret values were read. Image publication therefore
remains blocked until the owner provisions the required registry secrets.
On the host, the fixed root receiver uses the installed `gitea-api` helper to
re-fetch and verify the exact successful CI and release runs, current `main`,
artifact name, checksum, manifest schema, builder pin, image policy, and commit
binding. Its binary `DOWNLOAD` extension accepts HTTPS and at most one
allowlisted HTTPS redirect, does not forward the token across origins, bounds
the response, and refuses to overwrite an existing file. The installer checks
the exact audited helper fixture hash before applying its patch. The helper
and source code are not installed on the VPS by this PR.
## One-shot runtime binding and bootstrap compatibility
`main` includes a root-only, read-only status command and a fixed SSH dispatcher.
The dispatcher accepts only the literal status or deploy command in
`SSH_ORIGINAL_COMMAND`; it accepts no arguments and performs no shell
evaluation. Status verifies successful current-main provenance, artifact
metadata/ZIP, and host runtime compatibility before returning only a run ID,
commit, and descriptor digest in a `prepared` response. It fails closed on a
recovery journal, incomplete Gitea run listing, invalid release data, changed
host runtime configuration, or an already applied release. The root receiver
independently repeats release verification and the existing backup/ACK/cutover
guards.
The Windows `--once` mode only polls the fixed read-only status endpoint and
stores a protected local `PREPARED` tuple. It never sends a deploy request.
Automatic deployment uses a second task and a separate
`release-auto-apply.json` gate; both config files and both scheduler XML
templates default to disabled. The apply task acts only when its current
verified tuple exactly matches a tuple saved by a previous poll. An owner can
also use `--apply-prepared`, which requires an interactive exact-SHA
confirmation. Both deploy paths re-enter the existing root receiver and all
backup/ACK/cutover guards. Rollback remains a separate explicit owner-run
командой. Планировщик запускает задачи от интерактивного владельца с минимальными
правами, пропускает повторный запуск при активном экземпляре и не будит компьютер.
Этот PR не зарегистрировал и не включил ни одну задачу.
Сначала подготовьте bundle под обычной учётной записью из точной локальной
вершины `main` командой `prepare_windows_release_bundle.py`. Упаковщик читает
только Git objects указанного полного commit SHA, сверяет Git blob identity
исходников и создаёт небольшой ZIP с SHA-256; рабочее дерево он не читает и не
копирует. Elevated installer получает путь к ZIP, ожидаемый commit и SHA-256.
Он один раз открывает пакет с запретом записи/удаления, проверяет SHA-256 архива,
manifest и Git blob IDs в памяти, затем копирует только проверенные байты.
Дополнительно installer проверяет цепочку Git objects `commit → tree → файлы`.
На elevated этапе Git не запускается; `.git/config`, hooks и состояние checkout
не используются. Не изменяйте ZIP между подготовкой и установкой.
Упаковщик ставит всем ZIP entries тип regular file и mode `0100600`; installer
проверяет тот же mode (`0x8180`) до чтения содержимого. Для проверки стыка
упаковщика и фактической функции mode-валидации на Windows используйте
`powershell.exe -NoProfile -File infra/deploy/test_windows_release_bundle_mode.ps1 -PythonPath C:\Python313\python.exe`;
`git.exe` должен уже разрешаться обычным `PATH`. Harness создаёт синтетический
Git репозиторий и временный ZIP, извлекает только эту функцию из PowerShell AST и
проверяет принятие штатного пакета и отказ после изменения mode. Он не запускает
installer, не требует elevation, не меняет ACL и не регистрирует задачи; политику
выполнения PowerShell менять для него не следует.
Проверенный bootstrap installer проверяет подпись PSF у
`C:\Python313\python.exe` и защищённые ACL, затем размещает файлы клиента в
SHA-версионном каталоге `Program Files`, не перезаписывая существующую версию.
Для новых файлов кода владельцем назначается `Administrators`. У кода владельцем
могут быть только SYSTEM, Administrators или TrustedInstaller; учётная запись
задачи допускается владельцем только для отдельного private state/config.
Проверка ACL файла и защищённого каталога запрещает недоверенным субъектам запись,
добавление, удаление, удаление дочерних объектов, смену прав или владельца. Для
родительских каталогов проверяются права, способные подменить защищённый путь;
разрешение создать посторонний соседний каталог подменой не считается.
Installer сохраняет существующий canonical-файл
`C:\ProgramData\Akyldash\release-trigger.json`, только если он выключен; второй
disabled auto-apply config создаётся только при отсутствии. Installer формирует
два disabled task XML для проверки владельцем, но не регистрирует и не включает
задачи. Launcher запускает Python с `-B -E -s -S`, оставляет в import path только
стандартную библиотеку и защищённый каталог клиента, затем проверяет происхождение
импортированных модулей. До импорта receiver он заменяет унаследованный command
lookup фиксированными каталогами Windows, OpenSSH и Git, очищает `GIT_*` и
проверяет, что `ssh.exe`, `git.exe` и `powershell.exe` разрешаются в заданные пути.
Для самих бинарников и их родительских каталогов проверяются reparse points,
владельцы и ACL по правилам выше. Тесты подмены Python imports и command paths
выполнены на Linux; installer и launcher на Windows не запускались. Оставляйте оба
config gate и обе задачи выключенными до фиксации владельцем обычных backup/restore
и production readiness gate. Не привязывайте Windows-хост к Gitea workflow или PR runner.
The root status/deploy helper remains the reviewed root-installed bootstrap
code; it never checks out or executes target release code. A newer app release
is eligible without manually advancing `/opt/akyldash/current` only when the
host's Compose file, Caddyfile, and installed release policy match the exact
bytes at the prepared commit in Gitea. File metadata is pinned to that commit;
the Git blob SHA and size are verified. Status checks before preparing the
tuple; the receiver repeats the check before creating a journal or stopping
services, and cutover checks again after the verified external backup and
immediately before switching image references. A Compose/Caddy mismatch
returns `runtime_config_changed`; a policy mismatch returns
`release_policy_changed` and requires a reviewed owner bootstrap update. No
host checkout or root helper is automatically changed. The release must still
be current `main`; CI/release provenance, digest, backup, canary, and rollback
checks remain required. Gitea's filtered workflow-run listing is bounded to
ten entries; if Gitea reports more entries or an inconsistent page, status
returns unavailable rather than guessing.
## Automation boundary: deployment is not active
Until the owner separately activates both Windows tasks and the auto-apply
configuration after the backup/restore and production gates, the chain ends at
release publication. After that explicit one-time owner authorization, the
prepared-state poll and separate apply task can process later matching
application releases without advancing `/opt/akyldash/current` or approving
each commit individually. A release that changes protected runtime files still
requires a reviewed bootstrap update.
1. Successful `main` CI triggers image builds and publishes digest references
plus the release descriptor artifact.
2. `deploy-handoff.yaml` verifies the source run and may read artifact metadata.
Its job is explicitly status-only: **NOT DEPLOYED**. It does not download
the descriptor ZIP, call the Windows client, open SSH, or invoke the root
receiver. A green result means only that this status check completed.
3. The fixed receiver is physically started only by the separately enabled
owner-controlled auto-apply task or by an interactive owner command. The
`--once` preparation task stores a verified tuple and never sends deploy;
the separate apply task requires the tuple to match a fresh status response.
No repository workflow invokes either task. Both scheduler templates and
both configuration gates remain disabled, and neither task has been
installed or exercised.
Production CD is still not active: task/credential setup has not been verified,
and no VPS or live D: operation has been performed. This design avoids a
Windows Actions runner entirely; the owner-controlled local task is not exposed
to PR jobs. The SSH account/key must be restricted to the exact dispatcher
commands and verified host key. Do not use an unrestricted `authorized_keys`
entry or a persistent host-mode runner with D: and SSH access.
## External backup and cutover
The fixed root release receiver accepts only a bounded protocol request over
stdin. It does not accept a remote shell command, artifact path, or image
reference from the caller. It rechecks the release and current `main`, takes a
shared lock, captures running image IDs, and stops the stack. It streams the
cold archive directly to the owner's Windows client at
`D:\Dev\akyldash\.backups`; the VPS does not hold a full archive, including
temporarily. The Windows client requires a real NTFS destination with private
ACLs and a precise local Git exclude rule. It publishes only after hash,
archive, and manifest verification, and acknowledges the exact live session
nonce and backup identity.
Only after the acknowledgement does the host pull approved digest references,
atomically replace both image refs, and start Compose with `--no-build`. It
checks running image IDs, `/review`, and non-empty Russian and Kyrgyz search
canaries. A stale main, source mismatch, invalid archive, image mismatch, or
failed health/canary check stops cutover and attempts recovery using the
pre-change references. A recovery journal blocks another operation if
automatic recovery cannot be verified.
Rollback uses the root-pinned prior external archive and creates a fresh
external backup of current state before switching refs. It restores images
only, not data. Old local VPS backup paths and legacy release states without a
pinned external backup identity are rejected. See [backup and restore](BACKUP.md)
and [rollback](ROLLBACK.md).
The receiver records a durable pending recovery journal before stopping
services. A retry of the exact release already recorded in pinned state is
rejected before backup or downtime. Gitea source verification requires
`status=completed` and `conclusion=success`, and resolves current `main` from
the explicit production checkout. The Windows client checks the destination,
ACL, ignore rule, and full protocol archive capacity before opening SSH.
## Owner bootstrap still required
After review and merge, the owner must decide whether to install the helper
from a root-owned checkout and provision, without exposing values in logs:
- a read-only Gitea API token in `/etc/akyldash/gitea-api-token`, root-owned
mode `0600`;
- the exact artifact redirect origin allowlist in
`/etc/akyldash/gitea-download-origins`, if the Gitea artifact endpoint
redirects;
- approved Russian and Kyrgyz search canaries in
`/etc/akyldash/release-canaries.json`, root-owned mode `0600`;
- a root-only SSH path to `/usr/local/sbin/akyldash-release-deploy` and
`/usr/local/sbin/akyldash-release-rollback` using the existing owner key and
verified host key.
- a dedicated SSH alias `akyldash_release` backed by a key restricted to
`/usr/local/sbin/akyldash-release-dispatch`; its forced command must preserve
exact `SSH_ORIGINAL_COMMAND` matching and grant no shell, forwarding, PTY,
or arbitrary command access;
- a protected installation of the Windows client under
`C:\Program Files\Akyldash`, with both config files and both Task Scheduler
XML templates reviewed. Keep the preparation task/config separate from the
auto-apply task/config; the auto-apply pair remains disabled until the owner
explicitly accepts unattended production deployment.
The installer intentionally does not create credentials, SSH entries, canary
values, registry permissions, or a new privilege rule. Existing files with
unexpected owner, mode, or content cause installation to stop for owner
review. Do not run the installer or receiver from CI. The owner's first use
must be a separate-target restore check and a reviewed dry run; this PR does
not authorize a production cutover.
The Windows client uses the existing OpenSSH host-key checking. Its configured
alias must reach a root-controlled endpoint capable of invoking the fixed
receiver; no new key or sudo permission is requested. Windows ACL and NTFS
checks, SSH host configuration, artifact redirect origin, registry pull
access, and every production health gate remain unverified until the owner
checks them.
+125
View File
@@ -0,0 +1,125 @@
# Пакет проверок владельца перед деплоем
Здесь перечислены проверки, которые выполняет владелец. Этот документ не
разрешает production-изменения. Не включайте значения токенов и паролей,
содержимое env-файлов, приватные ключи, другие секреты или полный вывод
переменных окружения.
## Что требуется до использования CD
- Отключённый одноразовый Windows trigger из PR #59 уже находится в `main`, но
не установлен и не запускался. Bootstrap из этого PR также не установлен.
После ревью и слияния обновите локальный `main` до проверенного полного SHA.
Под обычным пользователем подготовьте bundle командой
`prepare_windows_release_bundle.py --repository D:\Dev\akyldash --commit <SHA> --output <new ZIP path>`.
Упаковщик требует, чтобы SHA совпадал с локальной вершиной `main`; он читает
Git objects этого commit, а не рабочее дерево, проверяет SHA-1 объектов
commit/tree/blob и печатает SHA-256 ZIP. Передайте именно эти SHA и путь
elevated installer:
`install_windows_release_client.ps1 -ExpectedCommit <SHA> -BundlePath <path> -ExpectedBundleSha256 <digest>`.
Installer открывает ZIP один раз с запретом записи и удаления, сверяет SHA-256,
manifest и цепочку `commit → tree → файлы` в памяти и только после полной
проверки копирует эти байты. Elevated installer не запускает Git и не читает
`.git/config`, hooks или файлы checkout. Не меняйте ZIP между подготовкой и
установкой; сохраните SHA и полный commit для журнала владельца.
- Installer проверяет подпись PSF у `C:\Python313\python.exe` и защищённые
ACL, создаёт версионный каталог в `Program Files` без перезаписи существующей
версии и назначает владельцем новых файлов кода `Administrators`. Кодовые
пути должны принадлежать только SYSTEM, Administrators или TrustedInstaller;
учётная запись задачи допускается владельцем только для отдельных private
state/config. ACL-проверка файла/кодового каталога выявляет запись, добавление,
удаление, удаление дочерних объектов, смену ACL и владельца. Проверка родительских
каталогов выявляет права, позволяющие заменить защищённый путь; создание
постороннего соседнего каталога само по себе не считается подменой.
- Installer сохраняет существующий `C:\ProgramData\Akyldash\release-trigger.json`
только если он остаётся отключённым, создаёт disabled auto-apply config лишь
при отсутствии и формирует два disabled Task Scheduler XML для проверки.
Он не регистрирует и не включает задачи. Путь trigger известен, но содержимое
и его enabled-состояние не читались.
- Launcher использует проверенный `C:\Python313\python.exe` с `-B -E -s -S`,
проверяет происхождение импортированных модулей, заменяет унаследованный `PATH`,
очищает `GIT_*` и фиксирует разрешение `ssh.exe`, `git.exe` и `powershell.exe`
в доверенные пути. Задача подготовки сохраняет защищённый `PREPARED` tuple и
никогда не отправляет deploy-запрос.
- Задача auto-apply и её отдельный конфиг тоже остаются выключенными. Одного
`enabled` в preparation config недостаточно для деплоя. Auto-apply разрешён,
только если свежий root status точно совпал с tuple, сохранённым предыдущим
опросом. После явного согласования владельцем unattended deployment отдельное
подтверждение каждого SHA не требуется. Включайте preparation config и задачу
только после отдельного разрешения на опрос; auto-apply config и задачу — только
после отдельного разрешения на unattended production deployment и выполнения
всех backup/restore gate. Отключение любого auto-apply control останавливает
будущие автодеплои; уже начатый cold backup/deploy должен завершиться либо быть
восстановлен по процедуре.
- Ключ `akyldash_release` должен быть отдельным и привязанным к root forced-command
dispatcher с OpenSSH `restrict` (или эквивалентным запретом PTY и forwarding),
проверкой host key и точным сопоставлением status/deploy-команд. Не добавляйте
unrestricted shell или команду в root `authorized_keys`. Существующий ключ и
путь rollback остаются отдельным ручным действием владельца. PR не создаёт,
не копирует и не устанавливает ключи.
- Решите, допустим ли unattended production CD. Сейчас он не включён:
`deploy-handoff.yaml` остаётся только status-проверкой и сообщает
`NOT DEPLOYED`; обе локальные задачи и оба config gate выключены. Windows-хост
не открыт workflow и PR runner. Убедитесь, что root status command установлен
из проверенного bootstrap и ключ forced command ограничен.
- Checkout на хосте не обязан совпадать с текущим `main`: status сравнивает
защищённые root Compose/Caddy файлы и установленную release policy с файлами
точного candidate SHA через pinned Gitea Contents API и проверяет Git blob IDs
и размеры. Receiver повторяет сравнение до остановки сервисов; cutover повторяет
его после backup. Несовпадение требует отдельного проверенного owner bootstrap
update; код release или root helper автоматически не выполняется и не обновляется.
Текущий `main` и CI/release provenance всё равно перепроверяются перед apply.
Endpoint закрывается при неполной странице workflow-run API (более десяти
кандидатов или несовпадение count). Перед включением проверьте поведение этого
API на установленной версии Gitea.
- Подтвердите имена registry packages и минимальные права записи/чтения для
`admin/akyldash/backend` и `admin/akyldash/opensearch`; сохраните digest не
короче rollback window. Последняя read-only проверка Gitea: main CI run #706
завершился успешно, image release run #708 завершился ошибкой из-за отсутствия
repository secrets `REGISTRY_USERNAME` и `REGISTRY_PASSWORD`, status-only
handoff run #709 был пропущен. Добавьте эти secrets через настройки Gitea;
не помещайте значения в Git или логи.
- Проверьте, что срок хранения Gitea artifacts покрывает rollback window.
Workflow запрашивает хранение небольшого release descriptor и checksum на 90 дней.
- Подготовьте датированный очищенный root audit: SHA установленного checkout,
Compose services и image IDs, состояние health, пути и режимы volumes,
владельца/режим защищённых файлов, сводку listeners и состояние recovery journal.
- Подтвердите root-controlled SSH путь для фиксированных deploy/rollback-команд,
используя существующую owner SSH-конфигурацию и проверенный host key. Репозиторный
`codex` не должен получать общий sudo, членство в Docker group или Docker socket.
PR не устанавливает SSH key или root alias.
- Если потребуется, установите узко ограниченный read-only Gitea API token в
`/etc/akyldash/gitea-api-token`, владелец root, mode `0600`. Запишите только
наличие и scope токена, не его значение.
- Проверьте origin скачивания Gitea artifacts. При redirect добавляйте только
точный HTTPS origin в `/etc/akyldash/gitea-download-origins`, root-owned,
mode `0600`. Исправленный helper не должен отправлять этому origin token.
- Добавьте одобренные владельцем RU и KY search canary queries в
`/etc/akyldash/release-canaries.json`, root-owned, mode `0600`. Убедитесь, что
они возвращают ожидаемые непустые результаты; не передавайте тексты корпуса.
- Проверьте TLS mode Cloudflare, продление origin certificate и текущую доступность
публичных `/review` и search.
## Backup и восстановление
- Полный архив хранится только на Windows-носителе владельца
`D:\Dev\akyldash\.backups`; не размещайте полный архив на VPS.
- Synthetic Windows protocol проверки и временная private-NTFS owner-ACL fixture
прошли. До использования проверьте реальный путь D: и локальное правило
`.git/info/exclude`; затем подтвердите, что одобренный backup публикуется только
после проверки hash и manifest.
- Подтвердите шифрование D: при хранении. Архив содержит production secrets и
private keys; протокол шифрует передачу, но не хранение файлов.
- Восстановите проверенный архив на отдельной изолированной цели и проверьте данные,
SQLite integrity, OpenSearch и необходимые secrets. Не проверяйте restore на production.
- Подтвердите текущие counts данных и индекса, corpus generation и совместимость
миграций. Откат образа не восстанавливает данные и не отменяет изменения схемы.
- Задайте срок хранения архивов и сохраняйте предыдущий проверенный архив весь
rollback window.
Installer не создаёт credentials, SSH configuration, canary values, доступ к
registry или новые sudo rules. PR не установлен и не запускался на production.
Windows validation прошла 12 synthetic client checks, 8 protocol checks и временную
private owner-ACL fixture на NTFS. Installer и launcher на Windows не запускались;
реальный путь D:, SSH, полный backup и restore на отдельную цель не проверены.
Не включайте deployment до фиксации владельцем результатов всех проверок выше.
+59
View File
@@ -0,0 +1,59 @@
# Infrastructure
## Intended layout
Production is planned for the dedicated Ubuntu VPS at `206.223.249.118`.
`deploy/production/compose.yaml` defines Caddy, a Python Search API, and private
OpenSearch 3.7. Caddy terminates HTTPS for `akyldash.kg` and proxies to the API.
The API is reachable only on private Compose networks; OpenSearch is reachable
only by the backend. Cloudflare should use Full (strict) only after the origin
certificate and DNS path are verified.
The API stores its account and review SQLite files beside the normalized corpus
under the persistent data directory. OpenSearch and Caddy use named volumes.
The API's review signing key is mounted from a host file; it is not passed in
process arguments or committed to Git. Docker logs are bounded to 10 MiB per
file and three files per service.
## Actual state
The 2026-10-11 read-only audit found a fresh Ubuntu 24.04.5 host with SSH only.
Docker, Git, a reverse proxy, application directories, Compose services, TLS,
and backups are not installed/configured. The `codex` account has no
non-interactive sudo. See [CURRENT_STATE.md](CURRENT_STATE.md) and the
[production inventory](../../infra/inventory/production.yml).
The Compose file is a repository target, not proof of deployment. The Caddy
image is pinned by digest, while backend and OpenSearch still build locally;
immutable registry images and automated deploy are not configured. No migration
framework exists. The application uses SQLite and OpenSearch; SMTP
registration/recovery is not implemented.
## Repository paths and target host paths
- Compose and Dockerfiles: `deploy/production/`.
- Host checkout target: `/opt/akyldash/current`.
- Environment configuration: `/etc/akyldash/production.env`.
- Secret file: `/etc/akyldash/review_secret`, mode `0600`.
- Application data: `/var/lib/akyldash/data`.
- Intended backups: `/var/backups/akyldash`.
All host paths above are targets; none existed at audit time. Root bootstrap is
prepared at [bootstrap-ubuntu-24.04.sh](../../infra/bootstrap/bootstrap-ubuntu-24.04.sh).
It does not edit SSH or firewall configuration, create credentials, deploy the
application, or grant `codex` Docker/root access.
## Network and operations
Target public TCP listeners are SSH `22` and Caddy `80`/`443`. Backend `8080`
and OpenSearch `9200` must remain private to Compose. The
firewall state could not be independently verified because the SSH account has
no sudo; do not change it without owner approval. The domain resolves to
Cloudflare edge addresses, but HTTP and HTTPS return `521`, so the origin is
unreachable. Cloudflare origin mode, certificate validity, and production
health have not been verified.
Use [DEPLOYMENT.md](DEPLOYMENT.md), [BACKUP.md](BACKUP.md),
[ROLLBACK.md](ROLLBACK.md), and [RECOVERY.md](RECOVERY.md) for operational
limits and procedures. Configuration changes must go through Git and review;
do not reconcile a Git/server difference by overwriting the VPS automatically.
+47
View File
@@ -0,0 +1,47 @@
# Recovery on a clean VPS
## Scope and safety
The bootstrap below prepares Ubuntu packages and directories only. It is not a
complete recovery: production secrets, data, reverse proxy, TLS, registry
credentials, backup restore, and deploy-helper still require owner-controlled
setup. Run only on the new recovery host after verifying its identity. Never
run recovery commands against the existing production by accident.
## Procedure
1. Provision a clean Ubuntu Server 24.04 host and confirm hostname, IP, console
access, and a tested owner recovery path.
2. Create the SSH-authorized `codex` account without sudo or Docker group access.
Preserve the host's existing root SSH and firewall policy until an owner has
reviewed the change.
3. Check out the approved Git revision, root-owned and not group/world-writable,
at `/opt/akyldash/current`, then run
`sudo bash infra/bootstrap/bootstrap-ubuntu-24.04.sh --recovery-target`. The
recovery mode refuses the inventoried production hostname/IP and writes a
root-only marker at `/etc/akyldash/recovery-target`. It installs Docker
Engine, Compose, Git, Docker log limits, and target directories.
4. Install the limited read-only verifier and rollback helper with
`sudo bash infra/deploy/install-helper.sh`. It validates the root-owned
checkout and gives `codex` access only to those fixed commands.
5. Configure DNS to point through Cloudflare to the recovery host and allow the
owner to review/open TCP `80` and `443`. The production Compose Caddy service
obtains its origin certificate automatically. Verify Cloudflare Full
(strict) end to end before cutover.
6. Transfer the protected backup to the new host through the owner's approved
channel. The archive contains the production environment and review secret;
keep it root-only and never paste secret values into chat.
7. Extract the verified archive to a new `/var/tmp/akyldash-restore-*` staging
directory with `infra/backup/archive.py`, then run
`sudo python3 infra/backup/apply_restore.py --staging-dir <staging-dir>`.
Apply requires the recovery marker, matching clean Git revision, empty data
path, absent Compose volumes/config files, and no running containers. It
refuses the inventoried production host and leaves services stopped.
8. Build the backend and OpenSearch images from the matching checked-out Git
revision and explicitly start Compose with `--wait`. Verify HTTPS and
container health, record the backend image ID as described in
[DEPLOYMENT.md](DEPLOYMENT.md), then record actual versions and checks in
[CURRENT_STATE.md](CURRENT_STATE.md).
Do not repoint DNS or retire the old host until the owner verifies recovery and
approves the cutover. The restore and cutover have not been practiced yet.
+34
View File
@@ -0,0 +1,34 @@
# Rollback
The current rollback implementation is code only. PR #59 has not been merged,
installed, or run against the VPS. No current production release state or
external backup has been verified; production rollback is therefore not
available yet.
After owner installation and separate-target restore validation, rollback is
an owner-controlled Windows-to-VPS session. The Windows client reads the exact
previous backup identity pinned in root-owned release state, validates the
saved archive and sidecar, then sends it over SSH. The VPS accepts the bytes
only when size and SHA-256 match that root-pinned identity. It verifies the
current deployment against release state, stops Compose, and streams a fresh
cold backup to Windows before changing either image reference.
The rollback helper verifies deployment-file hashes, previous image refs and
image IDs, and that the fresh backup matches the current image IDs. It does not
restore application data, OpenSearch contents, or secrets. It uses the shared
deployment lock, atomically switches both image references, starts Compose
with `--no-build`, verifies running image IDs and the local `/review` health
route, then records the new release state. Failures attempt to restore the
pre-rollback references and services; unresolved recovery is recorded in a
root-owned journal and blocks subsequent release/rollback operations.
The former `akyldash-rollback <VPS path>` interface now fails closed and performs
no Docker operation. Local `/var/backups/akyldash` archives cannot authorize a
rollback. Legacy release states without a verified external `previous_backup`
identity also cannot authorize this rollback path.
The application has no formal migration framework. Check schema and index
compatibility before rollback. If compatibility is uncertain, stop; do not
attempt a data restore as part of an image rollback. A data restore requires a
separate owner-approved recovery plan and maintenance window. See
[backup and restore](BACKUP.md) and [deployment](DEPLOYMENT.md).
+65 -17
View File
@@ -1,19 +1,37 @@
# Статус проекта
Последняя проверка: 2026-08-14
Последняя проверка: 2026-10-10
Назначение документа: быстро восстановить контекст проекта для участников команды и будущих агентов.
- Telegram-бот: `0.2.2`
- Telegram-бот на Synology: `0.2.1`
- Backend: `0.6.0`
- Frontend: не создан
- Backend: `0.12.2`
- Frontend: `0.1.2` — поиск, лаборатория релевантности, аккаунты
## Краткий итог
Репозиторий переориентирован с отдельного бота на весь проект юридической информационно-аналитической платформы. Telegram-бот выделен в инструмент рабочего окружения. Реализованы возобновляемая выгрузка документов из официального Open Data API ЦБД Минюста КР и их локальная воспроизводимая нормализация.
Ближайшая цель — оценить полный локальный индекс на 50–100 запросах RU/KY и
настроить ранжирование до начала разработки поискового API.
Поисковая система и внутренняя лаборатория оценки выдачи реализованы. Ближайшая
цель — проверить собственную выдачу на практических RU/KY-запросах, разобрать
оценки юристов и зафиксировать baseline. ЦБД Минюста служит источником для
проверки документов и редакций, а не системой для сравнения поисковой выдачи.
### Точка остановки на 2026-09-18
- В PR #37 из ветки `feature/tax-profit-search-intent` тематический запрос
«налог на прибыль» поднимает действующий НК КР; для обычных запросов сохранён
`cross_fields`, добавлено усиление точной фразы. Нужна дальнейшая проверка на
контрольном наборе запросов.
- Независимое ревью поискового кода не выявило дефектов; замечание об устаревшем
handoff исправлено. Backend-тесты: 33 пройдены, 1 пропущен;
`git diff --check` чистый.
- Текущий локальный backend-контейнер при последней проверке был `0.10.2`;
изменения ветки (`0.10.3`) не развёрнуты. Не деплоить без отдельного запроса.
- В рабочем дереве есть постороннее пользовательское изменение `.gitignore`;
не включать его в коммиты и не удалять.
- Следующий шаг: повторно проверить полный diff PR #37 и после успешных проверок
передать его на слияние; затем продолжить оценку поиска на контрольном наборе.
## Уже сделано
@@ -77,6 +95,8 @@
- Полный проход завершён: 209 958 документов нормализованы без ошибок.
- Контрольная выборка RU/KY прошла проверки текста, фрагментов, ID и SHA-256.
- Добавлены строгий mapping и атомарный Bulk NDJSON-экспорт для OpenSearch.
- На предыдущей итерации использовались Excel/PDF-бланки юридической проверки;
текущий процесс опирается на relevance set и внутреннюю лабораторию.
### Развёртывание
@@ -90,8 +110,9 @@
### Развёртывание и сопровождение
- Docker-конфигурация развёртывания не хранится в репозитории.
- Не настроен CI/CD.
- Production Compose, Ubuntu bootstrap и drift-проверка описаны в репозитории.
- CI workflow добавлен в PR #42, но его Gitea run остаётся в очереди; наличие
runner и автоматический production-деплой не подтверждены.
### Хранилище
@@ -112,6 +133,20 @@
- Не настроены уведомления Gitea.
- Не проведён полный пользовательский тест: сообщение → сохранение → экспорт → анализ в ChatGPT → возврат отчёта.
### Готовность поиска к публичному frontend
- Реализована внутренняя лаборатория для просмотра и оценки фактической
выдачи OpenSearch; пилот с юристами ещё не проведён.
- Не завершена независимая юридическая проверка всех 50 запросов relevance set
v1; спорные строки нельзя включать в baseline.
- Не зафиксированы неизменяемая копия подтверждённого relevance set и baseline
`Recall@10`/`MRR@10`.
- Публичный readiness gate остаётся открытым: нужны обновление корпуса,
переиндексация, проверка выдачи и API-сценарии для RU, KY и одноязычных актов.
- Создан внутренний авторизованный frontend поиска и лаборатории; первая
учетная запись `admin` создаётся CLI-командой, новых пользователей добавляет
только `admin`.
## Важные неизвестные
По мере реального использования нужно принять решения по следующим вопросам:
@@ -122,19 +157,32 @@
## Следующий этап
### Фиксация MVP и проверка полного цикла
### Проверка собственной поисковой выдачи
Рекомендуемый порядок:
1. Настроить резервное копирование `/volume1/docker/akyldash/data`.
2. Зафиксировать состав MVP с командой и закрыть тему `MVP`.
3. Провести одно реальное обсуждение с ответами, правками и вложением.
4. Экспортировать обсуждение и вручную проанализировать Markdown в ChatGPT.
5. Вернуть подтверждённый итог в тему `Решения` и зафиксировать его в Git/Gitea.
6. Записать обнаруженные неудобства и только после этого выбирать следующую доработку.
1. Подготовить согласованный RU/KY relevance set: фиксировать потребность
запроса и подтверждённые `document_code`, не ориентируясь на порядок выдачи.
2. Проверить практические запросы во внутренней лаборатории `/review`, сохранить
снимки и оценки фактических результатов OpenSearch.
3. Разобрать ошибки ранжирования, сохранить baseline и повторить ту же проверку
после изменений.
4. Затем пройти readiness gate issue #21 для корпуса, индекса и Search API перед
подготовкой публичного frontend.
## История изменений статуса
### 2026-09-12
- Внутренняя лаборатория оценивает фактическую выдачу собственного OpenSearch;
ЦБД Минюста используется только для проверки источников и редакций актов.
- Backend `0.8.3`; закрытая внутренняя лаборатория готова к пилоту.
### 2026-09-06
- Подготовлены бланки независимой юридической проверки relevance set v1 для
русского и кыргызского языков и инструкция для юриста.
- Ближайшим этапом зафиксированы юридическая верификация, baseline качества
поиска и финальный readiness gate перед frontend.
### 2026-08-18
- Оценщик поиска использует `cross_fields` для совместного сопоставления
@@ -234,4 +282,4 @@
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.6.0 · Frontend — не создан
Акылдаш · Telegram-бот v0.2.2 · Backend v0.12.2 · Frontend v0.1.2
+1 -1
View File
@@ -398,4 +398,4 @@ Git сохраняет актуальную версию
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.6.0 · Frontend — не создан
Акылдаш · Telegram-бот v0.2.2 · Backend v0.7.1 · Frontend — не создан
+15 -13
View File
@@ -1,19 +1,20 @@
# Готовность к проектированию frontend
# Готовность к публичному frontend
Этот документ определяет обязательный результат до начала проектирования и
разработки пользовательского интерфейса. Он дополняет
разработки публичной пользовательской системы. Он дополняет
[план frontend поисковой СПС](frontend-search-sps-plan.md): тот описывает MVP и
спринты frontend, этот — критерий перехода к ним.
## Правило перехода
Проектирование frontend начинается, когда выполнены все обязательные пункты
этого плана и пройден финальный readiness gate. До этого не создаются
`frontend/`, макеты, UI-компоненты или mock-данные, заменяющие неготовый
backend-контракт.
Публичный frontend и его выпуск начинаются, когда выполнены все обязательные
пункты этого плана и пройден финальный readiness gate. Внутренняя
авторизованная поисковая страница и лаборатория могут развиваться отдельно по
явной задаче и используют только реальные существующие API.
Вне этого этапа остаются аккаунты, уведомления, RAG, судебная практика и
внешние коммерческие сервисы.
Персональные функции вне этого этапа: закладки, заметки, уведомления, RAG,
судебная практика и внешние коммерческие сервисы. Внутренние учетные записи
для лаборатории не включают публичную регистрацию.
## 1. Данные и поисковый индекс
@@ -52,13 +53,14 @@ alias без смешивания старого и нового корпуса.
1. Заполнить рабочую копию из `backend/search/relevance-set-v1.template.json`
естественными формулировками пользователей и подтверждёнными
`document_code` релевантных действующих актов.
2. Проверить каждый запрос по официальной ЦБД и локальному индексу; не
включать запросы без установленного эталонного результата.
2. Подтвердить документы по официальной ЦБД и проверить их наличие в локальном
индексе по `document_code`, не оценивая порядок поисковой выдачи. Не включать
запросы без установленного эталонного результата.
3. Провести независимую вторую проверку языка, формулировки и полного списка
кодов. Спорные результаты не включать до согласования.
4. После проверки сохранить неизменяемую копию набора и baseline
`Recall@10`/`MRR@10`; новые правила ранжирования оценивать только сравнением
с этим baseline.
4. После независимой проверки сохранить неизменяемую копию набора, затем
оценить собственную выдачу и сохранить baseline `Recall@10`/`MRR@10`. Новые
правила ранжирования проверять на том же наборе и сравнивать с baseline.
5. Отдельно проверить распространённые сокращения, опечатки и запросы о
практическом действии. Правило принимается, только если улучшает
подтверждённые запросы и не создаёт ложных срабатываний в негативных
+14 -10
View File
@@ -105,6 +105,11 @@ MVP не входят.
## Рекомендуемая основа frontend
Для внутреннего frontend лаборатории и авторизованного поиска используется
одна HTML-страница, которую отдаёт существующий Search API, и браузерный
JavaScript без сборщика и runtime-зависимостей. Это сохраняет текущую схему
развёртывания. Публичный frontend после readiness gate можно оценить отдельно.
- Next.js App Router и TypeScript;
- CSS Modules с BEM-именованием;
- дизайн-токены для цветов, отступов, типографики и состояний;
@@ -131,13 +136,13 @@ TypeScript в стандартной конфигурации. См.
Правила применения:
1. До разработки экранов выбрать в Refero не более трёх подходящих направлений
и на их основе утвердить одно собственное направление Акылдаша.
1. Визуальное направление «Түндүк» утверждено пользователем. Применять
корневой [DESIGN.md](../../DESIGN.md); повторный выбор направления не нужен.
2. Не копировать чужую дизайн-систему целиком. Цвета, типографика, плотность и
компоненты должны учитывать длинные юридические тексты, два языка и
доступность.
3. Зафиксировать утверждённое направление в `frontend/DESIGN.md` и перенести
значения в дизайн-токены проекта.
3. Перенести значения из корневого `DESIGN.md` в дизайн-токены проекта.
Не создавать отдельный `frontend/DESIGN.md` или конкурирующие спецификации.
4. Дизайн-токены и компоненты Акылдаша являются источником истины. Внешние
рекомендации не могут отменять BEM, доступность, требования безопасности и
продуктовые ограничения проекта.
@@ -156,9 +161,7 @@ runtime-зависимостями frontend. Регистрация в стор
- создать `frontend/`;
- настроить Next.js, TypeScript, lint и сборку;
- выбрать до трёх референсов в Refero Styles и утвердить одно визуальное
направление;
- создать `frontend/DESIGN.md` с правилами выбранного направления;
- применить утверждённую дизайн-систему «Түндүк» из корневого `DESIGN.md`;
- установить полный набор `jakubkrehel/skills` для проектных design review;
- определить маршруты и типы API;
- создать дизайн-токены;
@@ -230,9 +233,10 @@ runtime-зависимостями frontend. Регистрация в стор
## Спринты после MVP
### Спринт 5 — персональный кабинет
### Спринт 5 — персональные функции
Авторизация, закладки, заметки, подборки и сохранённые фильтры.
Базовый вход и смена пароля для внутреннего frontend уже реализованы. Для
публичного продукта остаются закладки, заметки, подборки и сохранённые фильтры.
### Спринт 6 — контроль изменений
@@ -258,4 +262,4 @@ runtime-зависимостями frontend. Регистрация в стор
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.6.0 · Frontend — не создан
Акылдаш · Telegram-бот v0.2.2 · Backend v0.12.2 · Frontend v0.1.2
+462
View File
@@ -0,0 +1,462 @@
# План реализации и приёмки Акылдаш
Дата актуализации: **2 ноября 2026 года**. Основа сверки: `main` на
`7ece39f212b67e497a8dfb360d5d271ab088ef57`. Это единый план работ по продукту,
интерфейсу и переходу к production. Он не заменяет эксплуатационные runbooks
и не подтверждает состояние живого сервиса.
## 1. Статус и границы достоверности
Различаем три вида сведений:
- **В коде** — наличие исходного кода на указанном commit; это не доказывает,
что функция развёрнута или доступна пользователям.
- **Историческое наблюдение** — результат конкретного аудита, дата и пределы
которого указаны рядом. Оно не является повторной проверкой текущего сайта.
- **Цель / gate** — требование, которое нужно выполнить и подтвердить до
следующего этапа.
На сверенном commit источники кода показывают Backend `0.13.3`
(`backend/search/minjust_opensearch.py`), Frontend `0.3.3`
(`backend/search/review.html`) и Telegram-бот `0.2.2`
(`tools/telegram-bot/bot.py`). Это версии исходного кода, а не подтверждение
установленных production-версий. В этом PR согласованы версии в корневом и
общем документационном README; `backend/README.md` отдельно называет версию
загрузчика `minjust_cbd.py` (`0.9.3`). При новых изменениях сверять версию
компонента с соответствующим исполняемым кодом.
В Search API и frontend реализованы поиск RU/KY, фильтры, карточка документа,
редакции, поиск в тексте, внутренняя лаборатория, вход и административные
действия. В коде также есть локальные Noto Sans/Serif с лицензиями, favicon и
встроенный SVG-знак бренда. Наличие файлов и ссылок в HTML не подтверждает,
что они загрузились в live-браузере. Результаты браузерной проверки приведены
только в [отчёте визуальной приёмки](visual-review/README.md).
Production readiness не следует из наличия Compose, release workflow или
deploy-кода. Подтверждённое состояние и ограничения находятся в
[CURRENT_STATE.md](../operations/CURRENT_STATE.md); статус релиза — в
[DEPLOYMENT.md](../operations/DEPLOYMENT.md), ограничения backup/restore — в
[BACKUP.md](../operations/BACKUP.md), rollback — в
[ROLLBACK.md](../operations/ROLLBACK.md), аварийное восстановление — в
[RECOVERY.md](../operations/RECOVERY.md). Этот план не повторяет команды из
этих runbooks.
## 2. Очерёдность работ
### 2.1 Продукт и качество правовых данных
1. Поддерживать воспроизводимое получение и нормализацию официальных
правовых материалов с сохранением источника, редакции и контрольных сумм.
2. Подтвердить полноту и актуальность RU/KY корпуса, правила для отсутствующих
текстов и одноязычных редакций; не выдумывать перевод или реквизиты.
3. Развивать замороженный relevance set и измерять качество на одной и той же
выборке до и после изменений ранжирования.
4. Развивать версионируемые API и справочники, сохраняя совместимость
существующих клиентов.
5. Публичный frontend, персональные функции и RAG остаются отдельными
продуктовыми решениями и readiness gates. Сверяться с [обзором проекта](project-overview.md),
[планом frontend](frontend-search-sps-plan.md) и
[готовностью публичного frontend](frontend-design-readiness-plan.md). Конкурентная
гипотеза и сегмент рынка отдельно описаны в
[стратегическом сравнении с Токтом](platform-development-plan-toktom-2026-10-24.md).
### 2.2 Интерфейс: датированный реестр UI-01—UI-33
Реестр ниже сохраняет исходные наблюдения аудита **23 октября 2026 года** и
критерии приёмки; это не список текущих открытых дефектов. Актуальный план на
`main` указывает, что все UI-01—UI-33 реализованы в PR #54. Он также описывает
follow-up по поиску, кабинету и лаборатории, локальную браузерную проверку и
синтетические regression fixtures. Их результаты привязаны к указанным в
исходном документе commits и окружению, а не являются повторным live-аудитом в
этой документационной задаче. Сверяться с [отчётом visual-review](visual-review/README.md);
не открывать снова выполненные пункты без нового воспроизводимого свидетельства.
Источники по состоянию на базовый commit этого PR сохранены постоянными
ссылками: [UI-план на `7ece39f`](https://git.vakanaut.ru/admin/akyldash/src/commit/7ece39f212b67e497a8dfb360d5d271ab088ef57/docs/product/interface-correction-plan.md)
и [план pixel-perfect на `7ece39f`](https://git.vakanaut.ru/admin/akyldash/src/commit/7ece39f212b67e497a8dfb360d5d271ab088ef57/docs/product/frontend-pixel-perfect-plan.md).
UI-01—UI-33 вошли в [PR #54](https://git.vakanaut.ru/admin/akyldash/pulls/54)
(merge commit [`01380d3c`](https://git.vakanaut.ru/admin/akyldash/commit/01380d3c09d71e32c965d974a39a4d82de0a15c5));
последующий follow-up поиска, кабинета и лаборатории записан в
[commit `e5a8326a`](https://git.vakanaut.ru/admin/akyldash/commit/e5a8326a5e3def1bf5d5dc94794da82a2af801ab).
В исходном реестре **P1** означает обязательное исправление до визуальной
приёмки/выпуска, **P2** — существенное улучшение понятности и
последовательности. Отсутствие функции само по себе не считается багом;
обозначенные целевые корректировки требуют подтверждения по коду и дизайну.
| ID | Приоритет | Историческое наблюдение 23.10.2026 | Требование / критерий |
|---|---|---|---|
| UI-01 | P1 | При пустом запросе одновременно показывались подсказка и empty-состояние | Разделить initial/loading/results/empty/error; empty показывать после завершённого поиска |
| UI-02 | P1 | До выбора документа были доступны действия и пустые select | До загрузки документа показывать понятное приглашение; действия доступны после выбора |
| UI-03 | P1 | Фильтры имели горизонтальное переполнение (`clientWidth=194`, `scrollWidth=231` при 1064 px) | Исключить переполнение; длинные списки раскрывать по запросу |
| UI-04 | P1 | Действие «Сбросить» обрезалось заголовком | Заголовок и действие видны целиком на поддерживаемых размерах |
| UI-05 | P1 | Счётчики отрывались от подписей, переносы растягивали панель | Разделить колонки выбора/подписи/числа; переносить только длинные названия |
| UI-06 | P1 | Все 28 видов и 8 статусов были раскрыты, орган и даты находились ниже первого экрана | Показывать основные значения и сворачиваемые группы, сохраняя доступ ко всему каталогу |
| UI-07 | P1 | Ширины около 78 px не хватало для RU/KY названий языка | Подбирать размер по самой длинной локализованной подписи без обрезки |
| UI-08 | P1 | На 390 px навигация прокручивалась горизонтально, кабинет и фильтры уходили за край | Адаптивно показывать поиск, фильтры и кабинет без горизонтальной прокрутки основных действий |
| UI-09 | P2 | Кабинет был малозаметной иконкой во второй строке | Подписанная desktop-кнопка; на mobile — доступное имя и достаточная touch-область |
| UI-10 | P1 | Три колонки оставались тесными на 1024/1064 px | Перейти к двухпанельному режиму и скрытию фильтров, не сжимать колонки бесконечно |
| UI-11 | P2 | Название документа повторялось в breadcrumb, заголовке и тексте | Breadcrumb обозначает контекст; официальный текст не переписывать |
| UI-12 | P1 | Ссылка на источник и toolbar переполнялись на промежуточной ширине | Переносить действия группами без обрезки; сохранять подписи редакции и языка |
| UI-13 | P2 | Пустые абзацы и синие ссылки конфликтовали с оболочкой | Нормализовать отступы и ссылки без потери структуры официального текста |
| UI-14 | P1 | Таблицы в документе шире панели (`clientWidth=412`, `scrollWidth=834`) | Дать таблице отдельную обозначенную прокрутку; не прокручивать всю страницу по горизонтали |
| UI-15 | P1 | Встречались Temp-пути и HTTP-ссылки исходного редактора | Классифицировать ссылки безопасно, не удалять юридический текст; не считать локальные/IP-ссылки проверенными |
| UI-16 | P2 | Развёрнутый документ терял поиск, совпадение и toolbar | Сохранять редакцию, язык, позицию и навигацию; обеспечить очевидный возврат |
| UI-17 | P2 | Часть KY-интерфейса оставалась на русском | Инвентаризировать строки интерфейса; не заменять переводом язык документа |
| UI-18 | P1 | На `/review` отображались два footer, общий footer был выше сохранения | Один footer после основного содержимого и панели сохранения |
| UI-19 | P1 | Шапка, формы, панели и footer лаборатории имели разные направляющие | Единая оболочка и совпадающие границы контента |
| UI-20 | P1 | Высокая шапка лаборатории отодвигала работу на mobile | Компактный заголовок и помощь; поиск и рабочая область доступны сразу |
| UI-21 | P1 | Заголовок кабинета был пустым | Видимый заголовок и доступное имя диалога |
| UI-22 | P1 | Не было постоянного крестика; закрытие находилось внизу формы | Крестик 44×44, доступное имя, Escape и возврат фокуса; отмена редактирования остаётся |
| UI-23 | P1 | Навигация, выход, профиль и смена пароля были смешаны | Разделить «Профиль» и «Безопасность», отделить выход от формы |
| UI-24 | P1 | Окно пользователей открывалось поверх кабинета | Не оставлять конкурирующие модальные окна; сохранять контекст и корректный фокус |
| UI-25 | P1 | Список пользователей был длинным модальным окном с закрытием ниже экрана | Отдельный экран списка и короткие диалоги для создания/сброса |
| UI-26 | P2 | Создание, удаление, сброс и входные данные смешивались | Действия на строке, отдельное подтверждение удаления, не раскрывать лишние формы |
| UI-27 | P2 | Бренд, навигация, шрифты и ссылки различались между экранами | Общие header, logo, button, link, dialog и footer |
| UI-28 | P2 | Диалоги закрывались разными способами | Единая модель окна и доступного закрытия |
| UI-29 | P2 | Обучение появлялось с задержкой и перехватывало ввод | Запуск по запросу или ненавязчиво до начала ввода; не отбирать фокус |
| UI-30 | P2 | Шкала 0–3 переносилась, комментарии всегда занимали место | Полные подписи; допустима сетка 2×2; раскрываемый комментарий без потери текста |
| UI-31 | P2 | Пустой экран лаборатории выглядел готовым к отправке оценки | Явное состояние снимка, прогресс и статус черновика; не менять смысл пустой оценки |
| UI-32 | P2 | Footer live-сайта показывал «Концепция Түндүк» | Рабочий интерфейс показывает продукт и реальные версии; концепция подписывается только в макете/демо |
| UI-33 | P1 | Документ растягивал лабораторию до y≈636935 px, сохранение уходило ниже | Ограничить высоту области чтения и прокручивать её отдельно; сохранение доступно независимо от длины текста |
Дополнительные целевые критерии исходного аудита:
- **Сетка и ресайз колонок:** desktop — три колонки с доступными разделителями;
ориентиры 1440 px: `280 / 408 / 736` плюс два захвата по 8 px, 1280 px:
`256 / 384 / 624` плюс захваты. Рекомендуемые пределы фильтров 256–360 px,
выдачи от 360 px, документа от 560 px. При недостатке ширины сначала скрыть
фильтры, затем показывать документ отдельно.
- Разделители должны иметь `role=separator`, доступное имя, min/max/current
значения, pointer drag и управление стрелками клавиатуры. Отдельные кнопки
скрытия/показа фильтров и сброса ширины обязательны; двойной клик не заменяет
доступный сброс. Сохранённая ширина ограничивается текущим viewport.
- Остальные правила сетки: фильтры не становятся постоянно прокручиваемой
узкой колонкой; полный каталог значений всегда достижим; API single-select не
изображается множественными checkbox. Длинное название органа читается в
панели выбора полностью. Поля дат имеют постоянные «От» / «До» и переходят
в столбец, когда не помещаются. На desktop выдача и документ могут иметь
отдельную вертикальную прокрутку с доступными заголовком/toolbar; на mobile
основной поток вертикальный с отдельными панелями. Footer использует
растущий main (`min-height: 100dvh`), не перекрывает контент и следует за ним;
панель сохранения имеет собственное место.
- **Панели и формы:** понятный заголовок и крестик 44×44; именованный modal
dialog, фокус внутри, Tab/Shift+Tab без выхода в фон, Escape там, где закрытие
допустимо, возврат фокуса инициатору; фон не интерактивен. Не наслаивать
кабинет и пользователей. Профиль и безопасность — отдельные разделы; права
по-прежнему проверяет сервер. В форме пароля постоянные подписи, требования,
доступное показать/скрыть и подтверждение нового пароля. Пароли не
предзаполнять и не сохранять в материалах аудита; ввод выполняет человек.
- Основное действие одно; отмена вторична, выход отдельно; Enter отправляет
только активную форму. Ошибка у поля и в доступном сообщении, допустимый ввод
сохраняется, loading исключает повторную отправку. Несохранённое редактирование
защищено от случайной потери, обычный просмотр закрывается без лишнего
подтверждения. После успеха сообщается результат, при ошибке есть путь
восстановления; состояние не кодируется одним цветом.
- Правила применяются к кабинету, входу, обязательной смене пароля, источникам,
созданию/сбросу/удалению пользователя, обучению и просмотру документа.
- **Приёмка:** единая оболочка/header/footer; нет общей горизонтальной
прокрутки; initial не сообщает об empty до поиска; действия документа
активируются по состоянию; keyboard и touch работают; RU/KY подписи и язык
документа независимы; таблицы прокручиваются отдельно; тестируются contrast
WCAG AA, видимый focus, reduced motion, zoom/reflow и доступные имена.
- Viewport: **1440×900, 1280×800, 1024×768, 390×844**, дополнительно 320 CSS px
для reflow. Визуальное сравнение — детерминированное fixture; нулевой
pixel-diff не является критерием. Авторизация и destructive-сценарии
проверяются только в безопасном тестовом контуре.
Исторически выполненные браузерные сценарии и ограничения аудита 23.10.2026:
| Область | Выполнено в том аудите | Не проверено тогда |
|---|---|---|
| Начальная страница и поиск RU | Пустой запрос, фильтры, footer; поиск «налог на прибыль», открытие кодекса | Экспертная оценка юридической релевантности |
| Фильтры, сортировка, страницы | Статус, chip, снятие фильтра, дата/релевантность, страница 2 и сброс на страницу 1 | Все комбинации справочников |
| Пустая выдача и документ | Контрольный заведомо пустой запрос, реальные текст и редакции, язык, разворот | Искусственное создание серверного сбоя, реальная печать/PDF |
| Поиск в документе и локализация | Совпадения, переход к следующему, переключение RU/KY и чтение подписей | Поиск после смены редакции; полная проверка всех переводов |
| Источники и мобильные фильтры | Открытие/закрытие панели и раздела источников | Внешний сайт Минюста, полный touch на физическом устройстве |
| Кабинет и пользователи | Профиль, поля смены пароля, Escape/возврат фокуса; просмотр списка | Ввод пароля, создание/удаление пользователя, сброс пароля |
| `/review` | Пустое состояние, поиск, шкала, документ, обучение | Отправка оценки и подтверждение серверного сохранения |
| Авторизация | Наличие локальной реализации | Live login, истечение сессии и обязательная смена пароля |
Этот перечень описывает только прежний браузерный сеанс. Не использовать его
как подтверждение текущих production-сценариев.
#### Реализация, зафиксированная в текущем плане `main`
UI-план, находившийся на базовом commit этого PR, отмечает UI-01—UI-33
реализованными в PR #54 и связывает группы изменений с
регрессионными сценариями:
| Пункты | Изменение, зафиксированное в плане `main` | Regression evidence, указанное там |
|---|---|---|
| UI-01—02 | Разделены initial/loading/results/empty/error; действия документа зависят от выбранной редакции | Empty, API error и редакция без текста |
| UI-03—08 | Компактные/сворачиваемые фильтры, счётчики, локализованные языковые select и адаптивная навигация | Reflow 320/390/640/1024/1280/1440; на 640 px проверены основные действия `/`, `/review`, `/users` |
| UI-09—10 | Подписанный кабинет и адаптивный layout вместо бесконечного сжатия трёх колонок | Геометрия и overflow на 640/1024/1280/1440 |
| UI-11—16 | Breadcrumb, toolbar, отдельный scroll таблиц, очистка небезопасных URL-атрибутов и разворот документа в панели | Синтетические private-IP/relative/HTTP ссылки; ошибки и гонки редакций A/B |
| UI-17 | Перевод интерфейсных подписей RU/KY с независимым языком документа | Переключение языка; полный ручной лингвистический review остаётся открытым |
| UI-18—20 | Один footer, общие направляющие и ограниченная по высоте лаборатория | Один footer и длинный синтетический документ с собственным scroll |
| UI-21—24 | Имя и разделы кабинета, безопасный переход к пользователям без наложенных диалогов | ARIA name, Escape, focus trap/return и отдельные маршруты |
| UI-25—28 | Отдельный экран пользователей, целевые окна для действий, общий способ закрытия | Только фиктивный `fixture-created`; реальные аккаунты/API не использовались |
| UI-29 | Обучение запускается пользователем | Локальный regression scenario |
| UI-30—31 | Оценка 2×2, раскрываемый комментарий, disable до результата и draft state | Фиктивное сохранение и сохранность оценок при ошибке/устаревшем ответе |
| UI-32—33 | Версии в footer и отдельная прокрутка длинного текста лаборатории | Footer versions и длинный fixture, сохранение остаётся доступно |
Тот же план `main` фиксирует локальный browser run на **320×800, 390×844,
640×450, 1024×900, 1280×800 и 1440×900 CSS px**; на 1280 px измерена
сетка 256 / 384 / 640 px. Экран лаборатории с длинным текстом имел собственную
панель прокрутки, а сохранение/footer оставались доступны. Встроенная
проверка fixture не использовала production API или реальные учётные записи.
Это точная передача прежнего отчёта; её не следует трактовать как результат
нового прогона в этом PR. Полный screen-reader review, 200–400% zoom,
физический touch и полный ручной лингвистический review в исходном плане
оставлены непроверенными.
Отдельный follow-up в плане `main` описывает реализованные после PR #54
корректировки и regression coverage:
- **Поиск:** «Открыт» сбрасывается при смене результата/редакции и ставится
только после успешной загрузки. Оглавление распознаёт самостоятельные
заголовки RU/KY из структурированных абзацев (`p > b > span`), задаёт
уникальные якоря и вложенность, не принимает обычные упоминания за заголовки.
Выбор языка поиска находится в фильтрах и не обрезается; декоративный SVG и
пустое резервное место удалены; активная навигация использует палитру.
Проверены ошибки и гонки загрузки, структура заголовков, splitter и
доступность диапазона его ширины.
- **Кабинет:** ссылки действий сохраняют `<a>` семантику, крестик остаётся
подписанной SVG-кнопкой 44×44, значения профиля стоят в парах «поле—значение»,
длинные значения переносятся, заголовок компактнее; администратору явно
доступно «Управление пользователями». Проверены RU/KY, отсутствие ссылки у
не-администратора, фокус/закрытие и ширина.
- **Лаборатория:** имя показывается без дублирующей роли; ссылки навигации имеют
вид кнопок; оболочка, форма, рабочая область, сохранение и footer выровнены.
Пустой live status не резервирует высоту, результаты и документ образуют
общую поверхность с внутренней прокруткой на desktop, заголовок использует
доступную ширину, а mobile возвращается к естественному потоку. Проверены
desktop/mobile, пустой status, сохранение после длинного документа и page
overflow.
Исходный план сообщает о локальной проверке этих изменений 24 октября 2026
года и повышении frontend до `0.3.0` из-за регулируемой ширины панелей. Текущий
source footer и версия кода — `0.3.3`. Его отметка «ожидаются CI и review»
относилась к конкретному pushed SHA и не задаёт статус этого или любого нового
PR.
#### Общие критерии окон и форм
- Desktop-окно обычно 480–640 px и не шире viewport; на mobile — отступ 16 px
или полноэкранный режим для длинной формы. Заголовок и закрытие остаются
доступны при прокрутке.
- Диалог именуется через заголовок, держит фокус внутри и не делает фон
интерактивным. Escape закрывает обычные окна; исключения (например,
обязательная смена пароля) должны объясняться, а не маскироваться.
- Профиль и безопасность разделены. Удаление требует отдельного подтверждения;
создание/сброс не раскрывают дополнительные формы без запроса.
- Поля имеют постоянные подписи; пароль не предзаполняется и не записывается
в отчёт. Ошибки объясняют восстановление, допустимый ввод сохраняется,
повторная отправка во время loading исключена.
- «Отмена» вторична, выход не смешивается с сохранением; несохранённое
редактирование защищено от случайной потери. Обычный просмотр закрывается
без лишнего подтверждения.
#### Последовательность при возобновлении UI-работ
1. Зафиксировать актуальные SHA и версии; читать маршрут, API, `DESIGN.md`,
UI tests и CI. Не переносить состояние live-сайта без новой проверки.
2. Исправить общую оболочку и разделение поиска, лаборатории и служебных
экранов; затем initial/loading/empty/error и жизненный цикл документа.
3. Исправить фильтры, адаптивные минимумы и RU/KY; после этого — регулируемые
колонки и скрытие фильтров.
4. Привести формы/диалоги к общей модели с сохранением серверной авторизации,
прав, валидации, снимков оценок и черновиков.
5. Проверить текст, таблицы, ссылки, редакции, печать, адаптивность,
локализацию, доступность и состояния на реальном корпусе, где доступен.
6. Сделать детерминированный fixture-проход и независимый review; обновить
версию только при изменении кода, а changelog — после зелёных проверок и
разрешения blocking findings.
Не переносить существующий Python frontend на React/Next.js ради визуальной
корректировки. UI-работа не меняет ранжирование, корпус или права доступа.
| Приёмка | Условие |
|---|---|
| Оболочка | Один header/footer на маршруте, единые границы и порядок контента |
| Фильтры | Нет общей горизонтальной прокрутки; подписи/действия/счётчики видны полностью |
| Начальное состояние | До первого завершённого запроса empty-сообщение не показано |
| Документ | До выбора неактивные действия не вводят в заблуждение |
| Колонки | Pointer и клавиатура; min/max; скрытие/возврат; reset; адаптация сохранённой ширины к viewport |
| Телефон | Основные действия без горизонтального скролла; понятный возврат из фильтров/документа |
| Окна | Заголовок, крестик, Escape, trap/return focus; окна не конкурируют слоями |
| Лаборатория | Поиск → чтение → оценка → черновик → сохранение; текст не теряется, панель доступна при длинном документе |
| Локализация | RU/KY labels и символы; язык UI не подменяет язык документа |
| Геометрия | При 1280×800 границы основных зон и отступы/размеры controls отличаются от измеренного fixture не более чем на 2 CSS px; согласованные исключения описаны |
| Типографика и таблицы | Noto действительно загружается; читаемый длинный текст; у широкой таблицы собственный скролл |
| Доступность | Контраст AA, focus, Tab/Shift+Tab/Enter/Escape, zoom/reflow, reduced motion и touch targets |
| Авторизация | Неверный пароль, expired session и обязательная смена проверены только в безопасном тестовом контуре |
| Снимки | 1440×900, 1280×800, 1024×768, 390×844 и 320 CSS px; стабильные fixture и объяснённые различия |
Допуск **до 2 CSS px** для границ крупных зон и отступов/размеров controls при
1280×800 перенесён из исторического pixel-perfect плана по стабильной ссылке
выше. Он применяется к одинаковым детерминированным fixture; расхождения
растра, вызванные сглаживанием текста или содержимым, сами по себе не дают
основания обрезать или уплотнять юридический текст.
#### Нормативный орнамент и фактическое состояние
`DESIGN.md` нормативно требует небольшой золотистый SVG-орнамент в свободной
нижней части desktop-панели фильтров, не перекрывающий длинные списки и не
резервирующий место за счёт управления. Follow-up из commit `e5a8326a` удалил
предыдущий декоративный SVG и его пустой резерв; в `backend/search/review.html`
на базовом `main` целевого орнамента нет. Это не выполненное требование, а
подтверждённое расхождение между `DESIGN.md` и реализацией. Дизайн остаётся
нормативным до отдельного решения владельца: UI-задача должна либо вернуть
орнамент в свободную область без влияния на управление, либо получить явное
одобрение на изменение `DESIGN.md` и подтвердить его визуальной приёмкой.
### 2.3 Инфраструктура и переход к production
Инфраструктурные источники содержали повторяющиеся этапы deployment и
устаревшие снимки VPS. В этом плане оставлены только gates; актуальные
подтверждённые данные и процедуры находятся в операционных документах.
1. **Аудит:** сверить commit `main`, CI, Compose, зависимости, порты, volumes,
healthchecks, данные и перечень env-переменных без вывода секретов. Для VPS
пользоваться только read-only инвентаризацией, разрешённой
[правилами доступа](../operations/ACCESS.md). Не переносить IP, версии,
DNS-статус или ресурсы из старых планов как текущие факты.
2. **Данные и конфигурация:** установить источник и допустимость корпуса,
формат, состав, checksum/manifest, размер и воспроизводимую цепочку импорта.
Обязательный gate любой передачи/публикации корпуса — процедура
[CORPUS_HANDOFF.md](../operations/CORPUS_HANDOFF.md): source → staging →
inventory/manifest и checksum comparison → атомарная публикация
проверенного staging-корпуса в `DATA_ROOT/minjust-normalized` → отдельная
проверка и импорт в индекс → OpenSearch alias switch. Runbook требует
непустые source и staging, явное решение по исключению code 6 и fail-closed
при неполных/несовпадающих данных. Атомарная файловая публикация и
возобновление/отказ после прерывания — обязательный, но ещё не реализованный
gate; до описания и проверки all-or-nothing механизма не заменять им
действующий корпус. Не смешивать его с атомарным переключением OpenSearch
alias. Следовать runbook без копирования его команд сюда; до прохождения
gates корпус в production не передавать и не индексировать. Не подменять
корпус пустым каталогом. Production env/секреты создаются вне Git; в
репозитории остаются только имена переменных и шаблоны без значений.
3. **Build/CI:** PR-проверки не получают production secrets и Docker socket.
Публикация образов должна фиксировать digest и provenance; release workflow
не равен deployment. Production CD не включать до прохождения owner gates,
безопасной сборки, backup и отдельного restore.
4. **Контролируемое изменение существующего production:** последняя датированная
проверка `https://akyldash.kg/review` вернула HTTP 200 25 октября 2026 года
([CURRENT_STATE](../operations/CURRENT_STATE.md)); установленный на хосте
SHA остался неизвестен. План относится к безопасному
обновлению существующего сервиса, а не к первому развёртыванию. До изменения
проверять реальные данные и конфигурацию, доступное место, опубликованные
порты, права, совместимость схем и backup readiness. Выполнять через
существующую ограниченную процедуру; не добавлять общие sudo/Docker-
привилегии. Базы данных и внутренние сервисы не публиковать в интернет.
DNS, reverse proxy и TLS
проверять до выпуска; для Cloudflare применять Full (strict). SSH/UFW и
сетевые ограничения меняются только после owner approval.
5. **Проверка и recovery:** проверять health/API, RU/KY canaries, доступность
HTTPS, сохранность данных и rollback в пределах его документированных
ограничений. Data restore — отдельная процедура в staging, не автоматический
побочный эффект отката образа.
6. **Автоматизация:** только после одобренного отдельного bootstrap и
end-to-end проверки; serial deployment, digest verification, backup ACK,
health gates и отказ без destructive cleanup. Не включать триггер только
потому, что workflow завершился зелёным.
**Backup/maintenance gate:** настройки CI, проверка конфигурации и подготовка
release provenance должны быть отделены от production cutover. Production CD
в `CURRENT_STATE.md` остаётся неактивным. Перед каждым фактическим production
изменением, включая миграции, создать и проверить внешний pre-change backup.
Полный cold backup требует остановки Compose и может выполняться только в
заранее согласованное с владельцем окно.
Ни полный архив, ни его временная копия никогда не записываются на VPS. По
текущей утверждённой схеме архив передаётся напрямую на фиксированный
`D:\Dev\akyldash\.backups`; фактическая готовность D:, SSH и отдельное
восстановление всё ещё не подтверждены. На VPS остаётся только secret-free
идентификатор/manifest согласно `BACKUP.md`. Политика — одна retained,
проверенная внешняя копия. При замене прежняя копия сохраняется до тех пор,
пока новый candidate не опубликован и не проверен; на время этой проверки две
копии могут сосуществовать. После подтверждения новой копии retention может
изменить только владелец явным решением; автоматическая очистка запрещена.
При неудачной публикации или проверке прежняя подтверждённая копия остаётся.
Локальные VPS cold-backup команды из старых документов superseded и
использоваться не должны.
#### Локальные generated reports
`/reports/` и `/tmp/` игнорируются по умолчанию, чтобы generated bulk не
попадал в обычный `git status`. Уже отслеживаемые historical evidence остаются
отслеживаемыми. Если конкретный отчёт нужен в Git, сначала проверить его
содержимое, удалить секреты, персональные данные, production corpus и
временные файлы, затем добавить только предназначенный к публикации файл
явной командой `git add -f reports/<path>`. Не добавлять каталоги целиком.
Нынешний статус не переопределяется этим roadmap: на сверенной документации
production CD остаётся неактивным, а backup/restore и внешний bootstrap имеют
непроверенные owner prerequisites. Все точные блокировки сверять с
`CURRENT_STATE.md` и `DEPLOYMENT.md` перед планированием действий.
## 3. Визуальные материалы и исходная база
### Макеты Figma
Целевые редактируемые экраны — гипотезы компоновки, а не работающий интерфейс,
источник юридических данных или доказательство доступности.
| Экран | Макет |
|---|---|
| 01. Поиск с документом, 1440×900 | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=3-2) |
| 02. Фильтры скрыты | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=3-173) |
| 03. Начальный поиск | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=3-288) |
| 04. Лаборатория и сохранение | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=3-395) |
| 05. Кабинет: профиль и закрытие | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-30) |
| 06. Кабинет: безопасность | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-229) |
| 07. Источники | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-428) |
| 08. Пользователи: отдельный экран | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-605) |
| 09–11. Телефон: выдача, чтение и фильтры (общий Figma-узел) | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-670) |
| 12. Эталон 1280×800 | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=7-74) |
| 13. Планшет: две панели | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=7-245) |
| 14. Кабинет на телефоне | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=7-360) |
| 15. Вход, создание и подтверждение удаления | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=8-118) |
Локальные target exports: [поиск](../design/tunduk-search-target.png),
[лаборатория](../design/tunduk-review-target.png),
[кабинет](../design/tunduk-account-target.png). Это изображения цели; они не
заменяют браузерную проверку.
В Figma экраны собраны из редактируемого текста, Auto Layout, SVG, локальных
переменных цвета/отступов и общих экземпляров кнопок; это не один растр.
Состояния 09–11 ведут на один общий мобильный узел, как в исходном плане.
Прокрутка длинной выдачи/текста показывает продолжение содержимого.
Splitter, закрытие и формы описывают целевое поведение, но не являются
работающим web-кодом.
### Исторические свидетельства
Аудит UI выполнен **23 октября 2026 года** в авторизованной сессии на
`https://akyldash.kg/`, где footer показывал Frontend `0.2.3`, Backend
`0.13.0`. Сверка SHA сервиса с checkout не выполнялась. Проверены
1440×900, 1280×800, 1024×768, 390×844 CSS px и дополнительный browser viewport
1064×1244. Пароли не вводились; создание/удаление пользователей и отправка
оценки не выполнялись; полный физический touch, screen-reader и принтерный
прогоны не выполнялись. Существующие локальные browser evidence и screenshots
собраны в [отчёте visual-review](visual-review/README.md). Ветка `main` не
содержит отдельный архив live screenshots этого аудита. Любое использование
результатов требует повторной проверки актуальной версии и окружения.
## 4. Постоянные правила дизайна и полномочия
[DESIGN.md](../../DESIGN.md) остаётся нормативной дизайн-системой: цветовые
токены, типографика, бренд, композиционные принципы и доступность не
дублируются здесь. Этот roadmap задаёт работы, исторические критерии и gates;
он не утверждает новый дизайн и не разрешает production deployment.
Все пункты плана отмечать выполненными только при наличии актуального
свидетельства. Изменения приложения проходят обычные тесты, версионирование,
CI и независимое review. Инфраструктурные изменения отражаются в
`CURRENT_STATE.md` только по фактическим проверкам. Никакие действия с
production, секретами, пользователями или правовым корпусом не следуют из
наличия этого документа. Root-only bootstrap и предоставление секретов
выполняет/утверждает владелец в защищённой среде; DEV готовит проверяемый код
и точный план, reviewer независимо проверяет безопасность и failure paths.
@@ -11,8 +11,13 @@
- загрузчик находится в `backend/ingestion/minjust_cbd.py`;
- сырой архив хранится в `data/minjust-cbd` и исключён из Git;
- в манифесте 209 811 успешно загруженных документов;
- 13 кодов остаются в таблице `errors` и отсутствуют в таблице `documents`;
- в read-only проверенном манифесте 209 958 документов;
- один код, `6`, остаётся в таблице `errors` и отсутствует в `documents`;
это не подтверждает отсутствие документа в источнике: при последней
проверке OpenData API возвращал HTTP `404` для списка, `GetDocument(6)`,
контрольного `GetDocument(1096)` и `CheckAvailable`;
- в нормализованном манифесте 209 958 успешных записей, schema `1`,
normalizer `1.0.0`; полная потоковая проверка всех файлов ещё выполняется.
- документ содержит `metadata.json` и каталог `editions`;
- редакция содержит `metadata.json`, `ru.html` и/или `ky.html`, иногда
изображения;
@@ -214,4 +219,4 @@ python3 backend/normalization/minjust_cbd.py
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.6.0 · Frontend — не создан
Акылдаш · Telegram-бот v0.2.2 · Backend v0.7.1 · Frontend — не создан
@@ -0,0 +1,307 @@
# План развития «Акылдаш»: конкурентный анализ «Токтом Онлайн»
Дата: 24 октября 2026 года. Статус: предложение для продуктового решения;
не утверждённый бюджет или срок выпуска. Изменения приложения этим документом
не выполняются.
## 1. Вывод и оценка шансов
**На текущем этапе «Акылдаш» не готов заменить профессиональную СПС целиком.
Шанс построить востребованный продукт есть, если начать с ограниченного
практического сегмента и доказать качество данных и экономию времени.**
Копирование всей широты конкурента потребует не только разработки, но и
постоянной юридической редакции, получения материалов, сопровождения клиентов
и продаж. Рекомендуемая стартовая гипотеза — поиск и контроль норм для
небольших юридических и бухгалтерских команд, работающих с ОсОО/ЖЧК,
налогообложением и трудовыми вопросами. Наличие отдельных поисковых улучшений
по ОсОО и налогам делает этот сегмент удобным для проверки, но само по себе
не подтверждает спрос.
| Сценарий | Оценка | Что должно измениться |
| --- | --- | --- |
| Быстрая полная замена привычной СПС | Низкие шансы сейчас | Нужны надёжный выпуск, полнота материалов и профессиональные инструменты |
| Дополнительный инструмент для одной практической задачи | Наиболее реалистичная гипотеза | Пилот должен показать измеримую пользу без обязательного отказа от прежней системы |
| Основное рабочее место небольшой команды | Условно достижимо после пилота | Нужны удержание, обновления, персональная работа и подтверждённая готовность платить |
| Массовая платформа с ИИ-консультантом | Долгосрочная гипотеза | Нужны проверяемые ответы, экономика обслуживания и контроль юридических ошибок |
Проценты вероятности успеха не рассчитываются: нет данных о числе клиентов
конкурента, продажах, удержании, нашей команде, бюджете и стоимости привлечения.
Цены, скорость и качество поиска систем в одинаковых условиях не сравнивались.
Техническая возможность создать функцию не доказывает коммерческое преимущество.
## 2. Основание и ограничения исследования
Изучено открытое [руководство «Токтом Онлайн»](https://online.toktom.kg/cms/Topic/273)
целиком: портал, учётные записи, продукты, поиск и работа с документами.
В таблице ниже номера означают разделы этого руководства. Описание функции
подтверждает её документирование, но не результат испытания платной системы.
Дата последнего обновления руководства не установлена; старый год в подвале
не позволяет определить актуальность отдельных инструкций.
Отдельная [страница продукта](https://online.toktom.kg/cms/Topic/292)
заявляет ежедневное обновление и работу через интернет с синхронизацией
персональных данных. Это заявления поставщика; их фактическое выполнение
не проверялось. Актуальный тариф на этой странице предлагается запрашивать.
База сравнения «Акылдаш»: актуальная на повторной сверке `origin/main`, commit
`c8d0086a9f20485c5efe6f9d3ccd476dd9f07065` (2 ноября 2026 года). Матрица
G01–G25, описание текущих возможностей в разделе 3 и этапы A–F в разделе 6
повторно сверены с этой базой. Проверены:
- [README](../../README.md), [обзор продукта](project-overview.md);
- [API](../../backend/search/api.py), [поисковый интерфейс](../../backend/search/review.html),
[учётные записи](../../backend/search/auth.py), [запросы](../../backend/search/query.py);
- [каталоги](search-catalog-v1.md), [план frontend](frontend-search-sps-plan.md),
[readiness gate](frontend-design-readiness-plan.md);
- [состояние инфраструктуры и корпуса](../operations/CURRENT_STATE.md),
[доступ](../operations/ACCESS.md), а также актуальные реализации Search API,
учётных записей и поискового интерфейса.
Матрица отражает возможности реализации на указанном commit, а не только
состояние прежней базы исследования. В частности, в текущем внутреннем контуре
администратор может создавать и удалять учётные записи и сбрасывать пароль;
публичная регистрация и восстановление по email по-прежнему отсутствуют.
Технические версии кода: Backend 0.13.3 (`APP_VERSION` в
`backend/search/minjust_opensearch.py`), Frontend 0.3.3 (подвал
`backend/search/review.html`), Telegram-бот 0.2.2 (`APP_VERSION` в
`tools/telegram-bot/bot.py`). Корневой README указывает Backend 0.13.2;
`backend/README.md` содержит более старые Backend 0.13.0 / Frontend 0.2.3.
При расхождении использованы версия backend и подвал интерфейса в указанном
commit. Эти версии не подтверждают развёртывание соответствующего кода.
Живой production, полнота локального корпуса и пользовательская доступность
не подтверждены текущими проверками. Нельзя считать функцию доступной клиентам
только на основании наличия кода.
## 3. Что уже есть у нас
В реализации есть полнотекстовый поиск RU/KY, фильтры по виду, статусу, группе
органа, юридической силе и дате; пагинация и сортировка по релевантности/дате.
Есть API карточки и редакций, чтение выбранной редакции и языка, оглавление,
поиск внутри текста, печать и переход на официальный источник.
Это **не нужно разрабатывать заново**.
Учётные записи SQLite, проверка полномочий на сервере,
смена пароля и административные создание, удаление и сброс пароля доступны
во внутреннем контуре. Лаборатория собирает оценки выдачи и комментарии
проверяющих. Это не публичная регистрация, не восстановление пароля по email,
не пользовательские заметки к нормам и не история исследований между устройствами.
По состоянию, записанному в `CURRENT_STATE.md`, публичный запуск и готовность
всего корпуса не подтверждены. В документе есть исторические проверки архива
и ограниченного staging; успешная обработка одного документа не доказывает
полноту и актуальность базы. RAG в README отмечен как ещё не созданный.
## 4. Функциональные пробелы
Обозначения: **нет** — в проверенных API, UI и профильных планах отсутствует
готовая возможность; **частично** — есть основа, но сценарий неполон;
**не подтверждено** — без аудита корпуса или рабочего сервиса вывод невозможен.
«Нет» относится к реализации указанного commit, а не ко всем внешним наработкам.
P0 — до публичного пилота; P1 — основное рабочее место; P2 — после спроса;
P3 — только при отдельном обосновании.
| ID | Возможность в руководстве «Токтом» | Состояние «Акылдаш» и необходимое дополнение | Приоритет |
| --- | --- | --- | --- |
| G01 | Регистрация, восстановление, соцвход (§1.1–1.2) | Частично: внутренние аккаунты есть; публичный onboarding и восстановление по email отсутствуют. Соцвход отложить | P1 / P3 |
| G02 | Организация и профиль (§3.1.1) | Частично: есть кабинет, но нет полноценной модели организаций | P2 |
| G03 | Счёт, платежи (§3.1.2) | Нет: коммерческий учёт и сверка оплат | P2 |
| G04 | Продукты, сроки, промокоды (§3.2.1–3) | Нет: тарифные права и жизненный цикл подписки | P2 |
| G05 | Подключение коллег (§3.2.4–5) | Нет: приглашения и права внутри организации; внутренние роли не заменяют это | P2 |
| G06 | Настройки выдачи (§3.2.6) | Частично: параметры доступны, серверного сохранения предпочтений нет | P1 |
| G07 | Тематические разделы (§3.3.1) | Частично: черновая таксономия есть, публичный тематический фильтр отсутствует | P1 |
| G08 | Судебные, справочные, зарубежные материалы (§3.3.1) | Не подтверждено: специализированные полные коллекции и их поиск не показаны; наличие отдельных актов в ЦБД не исключается | P2 / P3 |
| G09 | Новые документы (§3.3.3) | Нет отдельной пользовательской ленты поступлений | P1 |
| G10 | Избранное, изменения (§3.3.4) | Нет серверного списка и пользовательского контроля изменений | P1 |
| G11 | История документов/запросов (§3.3.5) | Нет синхронизированной истории исследования; URL браузера и история входов — другие данные | P1 |
| G12 | Обзор обновлений (§3.3.6) | Нет журнала новых, изменённых и отменённых актов для пользователя | P0 / P1 |
| G13 | Открытие по коду (§3.4.2) | Частично: API принимает код, отдельного пользовательского сценария нет | P1 |
| G14 | Расширенные реквизиты (§3.4.3) | Частично: нужны номер, раздельные поля названия/текста, тематический отбор, множественные значения и поиск без обязательного q | P1 |
| G15 | Детальные органы, публикация (§3.4.3.1) | Частично: API ограничен группами органов; нет полного реестра и фильтров по регистрации/источнику публикации | P2 |
| G16 | Шаблоны запросов (§3.4.3.2) | Нет именованных сохранённых поисков | P1 |
| G17 | Сортировка по органу/виду (§3.5.3) | Нет таких режимов и выбора направления; текущая дата имеет дополнительный приоритет статуса | P2 |
| G18 | Word: выдача/документ (§3.5.4; 3.6.6) | Нет DOCX-экспорта; браузерная печать уже есть | P1 |
| G19 | Справка и реквизиты (§3.6.1) | Частично: метаданные есть, полная читаемая паспортная карточка требует доработки | P1 |
| G20 | Связи, обратные ссылки (§3.6.7–8) | Нет собственного проверенного графа связей и обратного индекса; исходные HTML-ссылки не равнозначны | P1 |
| G21 | Предпросмотр, «Связной» (§3.6.9; 13) | Нет контекстного просмотра и связей на уровне конкретной нормы | P2 |
| G22 | Графические копии (§3.6.10) | Частично на уровне архива: готовая выдача оригиналов не подтверждена; вложения staging не верифицированы | P2 |
| G23 | Сравнение редакций (§3.6.12) | Нет сопоставления двух текстов и выделения изменений; переключатель редакций уже есть | P1 |
| G24 | Языковое сравнение (§3.6.14) | Нет параллельного просмотра; переключение RU/KY уже есть | P1 |
| G25 | Форум, сообщения (§2; 3.2.7–8) | Нет пользовательского сообщества; рабочий Telegram-бот команды его не заменяет | P3 |
Редакции, языки, оглавление и печать (§3.6.2–5) в перечень отсутствующих
функций не включены. Для сравнительного списка использовано
[руководство поставщика](https://online.toktom.kg/cms/Topic/273).
## 5. Стратегия отличия
Это гипотезы развития, а не установленные преимущества над конкурентом.
Отсутствие описания в его руководстве не доказывает отсутствия функции.
1. **Проверяемая актуальность.** На каждой норме показывать источник, редакцию,
время последней успешной проверки и ограничения данных. Разделять дату
получения, принятия, вступления в силу и период применимости.
2. **Практический двуязычный поиск.** Измерять RU и KY отдельно на реальных
задачах, сокращениях и опечатках. Не обещать кыргызскую морфологию только
из-за наличия ICU. Не подменять отсутствующий официальный текст переводом.
3. **Рабочая задача вместо набора документов.** Проверить сценарий
«найти применимую норму → проверить редакцию → сохранить доказательство →
получить сигнал об изменении». Он должен занимать меньше времени у пилота.
4. **Объяснение с проверяемыми ссылками.** После надёжного поиска добавить
ответы с точными цитатами и открытием фрагмента. При недостатке источников
система сообщает об этом; юридические выводы требуют проверки специалистом.
Удобный интерфейс и низкая цена сами по себе не обеспечат переход клиентов.
Открытые источники также не дают автоматического разрешения на любой способ
их использования. Права на каждый новый источник проверяются отдельно;
копирование базы и редакционных материалов конкурента в план не входит.
## 6. Последовательность развития и приёмка
Сроки ниже — предварительные диапазоны для команды из двух разработчиков
с регулярным участием юриста и частичным участием дизайнера/эксплуатации.
Это предположение для обсуждения, не оценка существующего штата. При иной
команде и после обследования данных сроки нужно пересчитать. Переходы
определяются результатами, а не истечением недель.
### Этап A. Данные и воспроизводимый запуск — P0, ориентир 3–6 недель
Зависимости: состояние официального API, доступный корпус и участие владельца
инфраструктуры. Назначить ответственных за данные, эксплуатацию и юридическую
проверку. Согласовать правила текущей редакции и обработки неизвестного статуса.
- Завершить квалификацию источника и полный аудит корпуса; отдельно проверить
языки, порядок редакций, контрольные суммы, ошибки, вложения и пропуски.
- Запустить воспроизводимый цикл проверки обновлений с журналом изменений
и безопасным переключением индекса. Из этого журнала строить G12 и G09.
- Выполнить существующий readiness gate, зафиксировать baseline и ограничения.
- Подготовить пилотное развёртывание, наблюдение за ошибками, проверенный backup
и восстановление в изолированной среде по существующим процедурам.
Приёмка: завершённый отчёт о полноте с объяснёнными исключениями; повторный
запуск не создаёт дубликатов; неудачное обновление сохраняет рабочий индекс;
сквозные сценарии RU/KY проходят; восстановление проверено. Рекомендуемая цель
актуализации — не более суток от доступности изменения у источника; сначала
измерить достижимость и только затем обещать SLA пользователям.
### Этап B. Поисковое рабочее место — P1, ориентир 4–6 недель
Зависимость: этап A. Закрыть G07, G13–14, G18–19 и уточнить G06.
Использовать готовые просмотр, печать и оглавление; не переписывать их.
- Утвердить темы и устойчивые идентификаторы; не превращать черновые хеши
классификатора в публичный контракт без проверки.
- Добавить точный номер и независимые поля поиска; разрешить отбор только
по реквизитам; определить семантику AND/OR и множественного выбора.
- Сделать паспорт документа, пользовательский сценарий открытия по коду и
DOCX с редакцией, языком, источником и временем выгрузки. Не обещать PDF-
экспорт сверх печати до подтверждения отдельной потребности.
Приёмка: 20 задач пилота успешно проходят путь от запроса до нужного фрагмента;
документы без языка/текста имеют понятное состояние; файл экспорта воспроизводит
выбранную редакцию; новые правила не ухудшают замороженный relevance baseline.
### Этап C. Возвращение к работе и контроль — P1, ориентир 3–5 недель
Зависимости: A–B; для внешних пользователей — выбранная модель доступа и
проверенное восстановление аккаунта G01. Для закрытого пилота допустимы
создаваемые администратором внутренние аккаунты без публичной регистрации.
Закрыть G06, G10–11, G16: серверные избранное, история и шаблоны; G09/G12
показать как пользовательскую ленту. Добавить настройки контроля и доставки
уведомлений только после появления надёжного журнала обновлений. Выбор канала
и согласие пользователя — отдельное решение; наличие бота команды не означает
готовность продуктовых уведомлений.
Приёмка: сохранённое доступно со второго устройства; данные разных аккаунтов
изолированы; удаление/отключение контроля работает; повторная обработка события
не дублирует сигнал. Правила хранения истории и удаления аккаунта документированы.
### Этап D. Анализ нормы — P1/P2, ориентир 5–8 недель
Зависимости: проверенные редакции, структура фрагментов и метаданные A–B.
Закрыть G23–24 и базовую часть G20; затем G21, G22 и G15 по результатам пилота.
Для связей сохранять источник, целевую редакцию и уверенность сопоставления.
Приёмка: юрист проверяет не менее 30 разнообразных пар редакций, включая
перенумерацию и перемещение статей; интерфейс отличает техническую правку от
содержательной, а неопределённость показывает явно. Параллельный RU/KY-просмотр
использует одну редакцию и не выдумывает соответствия. Неуверенные ссылки
не выдаются как подтверждённые связи. Текстовый diff не считается юридическим
заключением о дате применимости нормы.
### Этап E. Платный командный продукт — P2, ориентир 4–8 недель
Зависимость: подтверждённые повторное использование и готовность платить.
Закрыть G02–05 и недостающую часть G01. Сначала выбрать одну понятную модель
подписки и необходимый канал оплаты; не воспроизводить весь биллинг конкурента.
Приёмка: организация приглашает и отзывает участника; сервер проверяет права
и срок доступа; повторное платёжное событие не создаёт повторное начисление;
просроченная подписка не уничтожает сохранённые данные. Стоимость эксплуатации,
юридической редакции и поддержки учитывается в экономике тарифа.
### Этап F. Новые источники и ответы — P2/P3, отдельная оценка
G08, G17, G25 и оставшиеся расширения включать по измеренному спросу.
Для судебной практики сначала исследовать законность получения, полноту,
обезличивание и обновляемость; для экспертных материалов — лицензирование
и редакционную ответственность. Международные акты проверять в существующем
корпусе прежде, чем создавать отдельный загрузчик. Зарубежную базу и форум
не ставить на критический путь первого продукта.
RAG — отдельная разработка после A–D. Начать с поиска доказательств и цитат,
затем краткого ответа. Приёмка: замороженный экспертный набор, проверка каждой
ссылки и редакции, отказ при недостатке данных и ноль критических неподтверждённых
юридических утверждений на приёмочном наборе. Это критерий допуска, а не гарантия
безошибочности вне набора. Сравнение качества ИИ конкурентов пока не выполнено.
## 7. Проверка рыночной гипотезы и условия остановки
Параллельно этапу A провести 12–15 интервью с юристами и бухгалтерами, включая
пользователей действующих СПС. Собирать обезличенные задачи, частоту,
потери времени, критичные источники и причины отказа от нового сервиса.
Цены сравнивать после получения актуальных коммерческих предложений.
Предлагаемый пилот: 10–15 специалистов, 4 недели, 20 одинаковых задач с RU/KY
и простыми/сложными запросами. С согласия участников сравнить время и долю
правильных решений в текущем инструменте и «Акылдаш»; менять порядок систем,
чтобы уменьшить эффект обучения. Результат оценивает юрист по первоисточнику.
Цели ниже — предлагаемые пороги решения, не уже достигнутые показатели:
| Измерение | Предлагаемый порог | Решение |
| --- | --- | --- |
| Качество поиска | Recall@10 ≥ 0,90 и MRR@10 ≥ 0,75 на независимо проверенном наборе, отдельно RU и KY | При недостижении улучшать поиск до расширения функций |
| Практическая польза | Медианное время правильного выполнения задач меньше минимум на 25% | При отсутствии выигрыша пересмотреть сценарий и сегмент |
| Возвращаемость | Не менее 60% активированных участников пользуются продуктом на четвёртой неделе | При провале сначала исследовать причины, затем решать о биллинге |
| Готовность платить | Не менее трёх независимых организаций готовы на платный пилот по названной цене | Переходить к этапу E после проверки экономики |
| Доверие | Нет критических ошибок редакции/источника в приёмочных задачах | Любая такая ошибка блокирует публичные обещания надёжности |
Малый пилот помогает выбрать направление, но не даёт оценки доли рынка.
Если пользователям необходимы источники, которые мы не можем законно получить,
либо стоимость сопровождения превышает приемлемый тариф, сузить сегмент или
остановить выбранную модель до дальнейших вложений.
## 8. Ближайший рабочий цикл
1. Назначить владельца продукта и юридического редактора; согласовать сегмент.
2. Зафиксировать состояние корпуса и readiness gate на актуальной main.
3. Провести интервью, собрать задачи и заморозить критерии пилота.
4. Превратить этап A и минимальную часть B в отдельные задачи с зависимостями
и критериями приёмки. Согласовать связь с существующим планом frontend,
не отменяя его gate этим документом.
5. После пилота пересчитать сроки, стоимость и порядок C–F; выбрать, какие
пробелы действительно мешают продаже, а какие можно отложить.
Для реализации сохранять существующую архитектуру Python/SQLite/OpenSearch,
использовать текущие API и компоненты, добавлять зависимости только по
обоснованной необходимости. Изменения проводить отдельными PR с проверками,
версией при изменении кода, независимым review и changelog. Production-действия
и изменение инфраструктуры требуют действующих процедур доступа и backup.
---
Повторная сверка реализации: `main` `c8d0086a9f20485c5efe6f9d3ccd476dd9f07065`.
Backend v0.13.3 · Frontend v0.3.3 · Telegram-бот v0.2.2. Это версии
исследованного кода; они не подтверждают развёртывание этих версий.
+1 -1
View File
@@ -44,4 +44,4 @@
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.6.0 · Frontend — не создан
Акылдаш · Telegram-бот v0.2.2 · Backend v0.12.2 · Frontend v0.1.2
+128
View File
@@ -0,0 +1,128 @@
# Справочники Search API v1
Статус: рабочий контракт Search API v1. Тематическая иерархия вынесена в
отдельный черновой каталог и не входит в фильтры API v1.
`code` — неизменяемый идентификатор, независимый от языка. Клиент хранит и
передаёт только `code`; русское и кыргызское названия являются подписями и могут
исправляться без изменения кода.
## Типы документов
| Code | RU | KY |
| --- | --- | --- |
| `constitution` | Конституция | Конституция |
| `constitutional_law` | Конституционный Закон | Конституциалык Мыйзам |
| `code` | Кодекс | Кодекс |
| `law` | Закон | Мыйзам |
| `decree` | Указ | Жарлык |
| `resolution` | Постановление | Токтом |
| `order` | Распоряжение | Распоряжение |
| `instruction` | Инструкция | Инструкция |
| `rules` | Правила | Правила |
| `procedure` | Порядок | Порядок |
| `provision` | Положение | Жобо |
| `regulation` | Регламент | Регламент |
| `charter` | Устав | Жобо (Устав) |
| `program` | Программа | Программа |
| `plan` | План | План |
| `strategy` | Стратегия | Стратегия |
| `concept` | Концепция | Концепция |
| `doctrine` | Доктрина | Доктрина |
| `agreement` | Соглашение | Соглашение |
| `declaration` | Декларация | Декларация |
| `registry` | Реестр | Реестр |
| `norms` | Нормативы | Нормативы |
| `model` | Модель | Модель |
| `matrix` | Матрица | Матрица |
| `study` | Исследование | Исследование |
| `report` | Доклад | Доклад |
| `principles` | Основные принципы | Основные принципы |
| `unspecified` | Не указан | Көрсөтүлгөн эмес |
## Статусы
| Code | RU | KY |
| --- | --- | --- |
| `active` | Действует | Күчүндө |
| `repealed` | Утратил силу | Күчүн жоготту |
| `unspecified` | Не указан | Көрсөтүлгөн эмес |
## Юридическая сила
Это укрупнённая группировка для фильтра Search API, а не полная иерархия
нормативных правовых актов из статьи 6 Закона КР «О нормативных правовых
актах». В этой группировке Конституция и конституционные законы относятся к
конституционному уровню, кодексы и законы — к законодательному, указы и
постановления, перечисленные законом как виды НПА, — к подзаконному.
| Code | RU | KY |
| --- | --- | --- |
| `constitutional` | Конституционный уровень | Конституциялык деңгээл |
| `legislative` | Законодательный уровень | Мыйзам деңгээли |
| `subordinate` | Подзаконный уровень | Мыйзам алдындагы деңгээл |
Для прочих типов значение не назначается автоматически: одного названия типа
недостаточно, чтобы установить юридическую силу утверждающего НПА. Основание:
[статьи 4 и 6 Закона КР «О нормативных правовых актах»](https://cbd.minjust.gov.kg/4-3987/edition/53304/ru).
Эта группировка реализована как фильтр `legal_force` Search API v1.
## Органы принятия
Орган содержит два уровня: стабильную группу и конкретный орган. В v1 группы
следующие:
| Code | RU | KY |
| --- | --- | --- |
| `president` | Президент | Президент |
| `parliament` | Органы законодательной власти | Мыйзам чыгаруу бийлик органдары |
| `cabinet` | Правительство и Кабинет Министров | Өкмөт жана Министрлер Кабинети |
| `ministries_and_committees` | Министерства и государственные комитеты | Министрликтер жана мамлекеттик комитеттер |
| `administrative_agencies` | Административные ведомства | Административдик ведомстволор |
| `national_bank` | Национальный банк | Улуттук банк |
| `other_state_bodies` | Иные государственные органы | Башка мамлекеттик органдар |
| `local_representative_bodies` | Представительные органы местного самоуправления | Жергиликтүү өз алдынча башкаруунун өкүлчүлүктүү органдары |
| `other` | Прочие органы | Башка органдар |
Конкретные министерства, муниципальные и айылные кенеши не получают ID из
подписи: в выгрузке ЦБД их поле `Code` часто равно `null`. До появления
первичного неизменяемого идентификатора API v1 выдаёт и принимает только код
группы органа. Полный каталог конкретных органов — отдельная версия (`v2`),
когда источник предоставит такие идентификаторы либо будет утверждён вручную
поддерживаемый реестр.
## Темы
Полная исходная иерархия `GeneralClassifiers` сохранена отдельно в
[`search-topic-taxonomy-v1.json`](search-topic-taxonomy-v1.json). Над исходными
ветвями добавлены предложенные смысловые группы; исходные подписи и вложенность
не переставляются. Поле `document_count` показывает число документов, где узел
встречается. Предлагаются группы: конституционный строй и права; государство и
публичное управление; право, суд и правопорядок; международные отношения;
оборона и безопасность; экономика, финансы и предпринимательство; труд и
социальная защита; здравоохранение; образование, наука и культура; земля,
природные ресурсы и окружающая среда; сельское хозяйство; транспорт, связь,
строительство и жильё; информация и СМИ; гражданские, семейные и жилищные
отношения; общие и межотраслевые вопросы.
Каталог является черновиком: группа корневой рубрики предложена по русской
подписи, рубрики с несколькими совпадениями правил или без совпадений помещены
в `review_required` и отмечены `manual_review`; остальные помечены
`provisional_lexical`. В корпусе 2 065 узлов и 687 вариантов корневых рубрик;
328 корневых вариантов ожидают ручной классификации. Отсутствующие в источнике
кыргызские подписи оставлены пустыми: они отсутствуют у 1 524 узлов, новые
переводы не придуманы. Внутренние `source-*` ID являются
временными хешами кода источника и цепочки подписей; при `Code: null` они
зависят от подписей и не утверждаются как публичные стабильные коды.
Тематическая навигация и фильтр не входят в Search API v1. До включения в API
нужно вручную утвердить смысловые группы, идентификаторы и недостающие KY-
подписи. Источник — нормализованные данные ЦБД Минюста; команда для пересборки
каталога находится в `tools/build_search_topic_taxonomy.py`.
## Правила совместимости
- Код из этой таблицы нельзя переиспользовать и нельзя менять его значение.
- Новое значение добавляется только новой записью; удалённое остаётся доступно
для старых документов.
- Запрос с неизвестным кодом возвращает `400`.
@@ -0,0 +1,318 @@
# Диагностика релевантности поиска — 3 ноября 2026 года
## Решения и границы
Эта заметка фиксирует исходные измерения от 2 ноября, узкое исправление
терминологии, условный multi-index scoring и контракт для закрытия пробела
корпуса. Выдачи и AI-аннотации не являются юридической оценкой. Целевой A/B
прогон и полный benchmark для итогового SHA описаны ниже; они не заменяют
экспертную оценку.
Изменён backend `0.13.3` → `0.13.4`, frontend внутренней лаборатории
`0.3.3` → `0.3.4`, версия алгоритма `search-6` → `search-8`. `search-7`
относится к предшествующему составу ranking semantics; новые подписанные
снимки этого PR помечаются `search-8`. Сохранённые отзывы не переписываются.
Финальный runtime/batch прогон кода выполнен на `1c7ddf2`; последующий коммит
этого отчёта и changelog меняет только документацию.
## Исходный benchmark
Зафиксированный набор содержит 7 500 русских формулировок: 15 тематических
групп, по 500 запросов в каждой. Это 750 тем с десятью вариантами формулировки,
а не 7 500 независимых правовых ситуаций. Исходный индекс
`akyldash-fragments-v2` содержал около 42,6 млн фрагментов и занимал 11,2 ГБ.
| Показатель Акылдаш, исходный прогон | Результат |
|---|---:|
| Собранные выдачи | 7 500 / 7 500 |
| Пустые выдачи | 6 |
| Медиана / P95 ответа | 21,67 / 77,36 мс |
| Совпадение основ темы в видимых названии/фрагменте, top 1 | 40,57% |
| То же, top 3 / top 10 | 47,05% / 51,40% |
Сравнение с КонсультантПлюс в исходном отчёте — только диагностическое:
юрисдикции и корпуса различаются. Совпадение основ темы не измеряет Recall,
MRR, полноту ответа или юридическую правильность; российская выдача не является
эталоном права Кыргызстана. Для вывода о качестве нужны экспертные релевантные
коды, контрольный набор и отдельные метрики.
## Подтверждённые классы ошибок
14 коротких документов прочитаны целиком; Codex (ИИ) подготовил по ним 79
предварительных оценок пар «запрос—документ» в 71 сессии: 59 помечены как
нерелевантные и 20 как косвенно полезные. Это тематическая AI-разметка для
триажа, а не заключение юриста или независимая экспертная проверка. Она не
подтверждает полноту правового ответа. Неоценённые результаты не считались
ошибками.
| Тема | Наблюдавшийся неверный приоритет | Вывод |
|---|---:|---|
| Расторжение договора аренды | 135066 | Решение по одной сделке с землёй не даёт общих оснований расторжения |
| Международный коммерческий арбитраж | 26411 | Продажа земельных участков не регулирует арбитраж и отказ в признании решения |
| Испытательный срок работника | 94603 | Земельный участок под завод/испытательный полигон совпадает словами, но не предметом |
| Место жительства ребёнка | 50760 | Финансирование приюта не устанавливает порядок спора родителей |
| Перевод студента в другой вуз | 14165 | Разовое финансирование студентов не устанавливает процедуру перевода |
| Таможенные пошлины | 41854 | Индивидуальная отсрочка одному предприятию не задаёт общие правила |
| Общий порядок предоставления земли | 54735, 54571 | Решения по отдельным участкам не являются общей процедурой |
| Защита критической инфраструктуры | 59082 | Предоставление земли под завод не устанавливает защитные требования |
| Купля-продажа / расторжение договора | 60053 | Расторжение одной сделки с госакциями не является общей нормой |
| Подача искового заявления | 18516, 7115 | Отдельный спор и узкая льгота по пошлине не дают общего порядка подачи иска |
| Принятие нормативных актов | 77190 | Первое чтение одного законопроекта не устанавливает общий процесс |
| Подключение к водоснабжению | 135061 | Целевая помощь отдельным домохозяйствам не задаёт технические условия |
Проверенные варианты глобального повышения веса кодексов отвергнуты: вес 2 не
исправил аренду и подачу иска, а вес 4 поднял Налоговый кодекс выше профильных
источников по воинской обязанности и подаче иска. Общие веса или жёсткие
приоритеты кодексов не добавлялись.
## Узкая терминологическая поправка
В исследованной редакции ГК КР II (код 5, редакция 460) глава 27 и статья 542
связывают договор аренды с имущественным наймом; статьи 558–559 используют
термин «имущественный наем». В полном тексте этой редакции буквальная фраза
«расторжение договора аренды» не встречалась. Исходный запрос возвращал код
135066 на первом месте, а формулировка «расторжение договора имущественного
найма» — код 5.
Backend теперь сохраняет исходную строку и добавляет только для точного
сочетания «договор [арендная форма]» вариант «договор [та же форма]
имущественного найма». В частности, `договора аренды` превращается в
`договора имущественного найма`. Расширение не срабатывает для аренды квартиры
или земельного участка, поднайма, найма работника или лизинга. Точный запрос
остаётся в строгой лексической ветке, поэтому его профильный муниципальный акт
не исключается из поиска.
В read-only пробе предыдущей версии `aa6fabe` для общего запроса код 5 поднялся
с позиции за пределами top 10 на #5, но нерелевантный индивидуальный акт 135066
оставался первым. На SHA `726b1ee` isolated code-only прогон на исходном индексе
поставил код 5 первым для общего запроса; из 7 500 запросов изменились 19
выдач, 7 481 совпала с baseline, число пустых осталось 6. Этот результат
проверяет `search-7` с `match_phrase` boost 12 для синтетической юридической
фразы, исходный phrase boost 3; он не описывает multi-index DFS. После
подключения трёх актов в отдельном supplement на `e653f3b` изменились 184
выдачи против code-only варианта и 38 top-1; все шесть пустых выдач
сохранились. Разные наборы индексов нельзя считать одним benchmark. Итоговый
conditional-DFS прогон на `1c7ddf2` приведён ниже. Контроль запросов об аренде
жилья/земли, поднайме, трудовых отношениях, международном праве и KY
формулировках выполнялся отдельно; rank-метрики не являются юридической
оценкой.
## Условный scoring для явного multi-index scope
При поиске по явно настроенному comma-separated scope (`base,supplement`) API
передаёт `search_type=dfs_query_then_fetch` как URL query parameter. Запросы по
одному настроенному индексу или alias сохраняют прежний `query_then_fetch`;
одиночные wildcard и alias не классифицируются по результату их expansion.
DFS включается только при запятой в настроенной строке scope.
Тело DSL, фильтры, query weights, сортировка, индексы и документы не меняются.
Параметр применяется только к `/search`; фильтры и остальные API вызовы
используют прежний режим.
OpenSearch описывает `query_then_fetch` как scoring по локальным term/document
frequencies шардов, а DFS — по глобальным frequencies через шарды; DFS обычно
точнее, но может быть медленнее. `search_type` поддерживается именно как
параметр Search API URL ([OpenSearch Search API](https://docs.opensearch.org/latest/api-reference/search-apis/search/)).
Поэтому ограниченный режим использует глобальные statistics для явной
multi-index конфигурации и не меняет behavior одиночного поиска.
На изолированном OpenSearch 3.7.0 оба режима сравнили на одинаковых DSL bytes и
одном неизменном наборе данных: 300 заранее выбранных запросов, максимум 300.
Порядок менялся в 93 случаях, top-1 — в 29; из 9 именованных риск-кейсов
изменились 3, все 5 выбранных positive controls сохранили top-1. Контроль с
индивидуальным актом 135066 сохранил этот акт первым. В пяти запросах о
педагогических/научных работниках и одном запросе о болезнях животных top-1 не
изменился. Эти шесть случаев требуют предметной оценки и не объявляются
автоматически нерелевантными: статья 150(8) закона об охране здоровья связывает
аттестацию педагогических/научных работников с медицинской деятельностью, а
случай с болезнями животных остаётся без экспертной оценки. Новые оценки пар не
выставлялись; специальные веса или document IDs не добавлялись. Сравнение
ограничено выборкой и не является разметкой юридической правильности.
Медианы 22,955 мс для default и 16,115 мс для DFS не используются как оценка
скорости: default всегда исполнялся первым, возможен cache/order bias.
Frozen experiment metadata is stored outside the repository in
`reports/search-benchmark-2026-11-02/dfs-probe-e653-summary.json`. The paired
response-capture artifact `dfs-probe-e653.jsonl` has SHA-256
`4a9373ba5236c5c5a1d85139b6dbe67bcf01df02216f8b2f292e5c451e6432c8`.
Выборка содержала base index (42 624 501 fragments) и read-only supplement
(8 934 fragments); UUID/count guards прошли, основной индекс и сохранённые
отзывы во время эксперимента не менялись. В PR не включены сырые ответы,
персональные данные или выдачи документов.
Кодовый тест проверяет URL, encoded multi-index path, RU/KY, пустую выдачу,
прежнее тело запроса и неизменность single alias/index и filters. Read-only
integration gate для root-owned runtime:
```bash
AKYLDASH_OPENSEARCH_URL=http://127.0.0.1:8081 \
AKYLDASH_OPENSEARCH_INDEX='akyldash-fragments-v2,akyldash-benchmark-supplement-20261103-r1' \
PYTHONPATH=backend python3 -m unittest \
backend.test_search_api_opensearch.SearchMultiIndexDfsOpenSearchTest.test_global_statistics_mode_accepts_read_only_multi_index_search -v
```
Root выполнил этот read-only gate на 8081 для `1c7ddf2`: фактический multi-index
URI использовал DFS и подписанный token сохранял полный scope. Gate выполняет
только поиск и не создаёт/удаляет индексы. Полный benchmark на том же code SHA
и frozen baseline приведён ниже; 300-query experiment и более ранние прогоны
остаются отдельными сравнительными срезами.
Откат к предшествующему приложению возвращает `query_then_fetch` для multi-index
поиска. Индекс, документы, saved reviews и старые подписанные токены при этом
не требуют изменений или миграции; новые отзывы, сохранённые до rollback,
остаются помечены `search-8`. Для supplement действует отдельный rollback
контракт ниже: отключение его подключения скрывает только добавленные редакции,
не удаляя подготовленные файлы и базовый индекс.
## Итоговый pinned прогон search-8
Полный isolated прогон для executable SHA `1c7ddf2fcbd7c55920470e7ac52339fdab0366db`
собрал 7 500 уникальных запросов с версией `search-8`. SHA-256 выдачи:
`fc02e22e5b3d612813ffc0bdaf9892bb16ee4c29af3e1b77f79e58a0a45c8ec1`. Итог
сопоставлен с frozen baseline и двумя явно указанными промежуточными режимами:
| Сравнение | Изменён порядок | Изменён top-1 |
|---|---:|---:|
| Исходный baseline `search-6` | 537 | 103 |
| Code-only `726b1ee`, `search-7` | 521 | 93 |
| Code + три акта, `e653f3b`, без DFS | 425 | 63 |
Это число изменений порядка, а не улучшений. Все шесть пустых выдач сохранились
во всех четырёх прогонах. Среди ранее проверенных Codex (ИИ) пар с нулевой
оценкой top-1 стало 26 вместо 28 в baseline; остальные 26 не объявлены
исправленными, и новые выдачи автоматически не оценивались.
| Добавленный акт | Наблюдался в top-1 / top-3 / top-10 |
|---|---:|
| Трудовой кодекс КР `230021083` | 24 / 58 / 105 |
| Земельный кодекс КР `230029465` | 4 / 11 / 26 |
| Закон об охране здоровья `230000918` | 34 / 51 / 92 |
Все шесть точных названий новых актов на RU/KY дали ожидаемый текущий акт первым.
Все 284 запроса, общие с целевым DFS экспериментом, совпали с ним по полному
порядку кодов и редакций. Проверки dataset guard подтвердили неизменность
исходного индекса, manifest, mappings и UUID/count, а также 115 подключённых
файлов. 7 503 записи локальной лаборатории совпали с backup; обе SQLite
integrity checks прошли. Записи оценок, комментарии и пользователи не менялись.
Шесть межтематических контекстов нельзя автоматически считать нерелевантными:
медицинский закон остался первым в пяти запросах о педагогических/научных
работниках и в одном запросе о болезнях животных. Для педагогических случаев
проверенная статья 150(8) связывает аттестацию этих работников с медицинской
деятельностью; новые оценки пар не выставлялись. Вопрос о животных также
остался без экспертной оценки. ИИ-аннотации не доказывают полноту правового
ответа или юридическую правильность ранжирования.
Режим и scope дополнительно проверены на runtime `8081`: для multi-index `/search`
уходит DFS, для одиночного индекса и filters — прежний default; тело DSL
одинаково. Alias проверен transport mock без создания alias. Runtime mode/API
smoke включал HMAC/TTL, signed scope, сохранение пустого результата и
отрицательные rank/code сценарии. Browser UI fixture прошёл на `726b1ee`; UI
побайтно не менялся в следующих SHA, но отдельный browser прогон на `1c7ddf2`
не выполнялся. Медиана backend `23.685 мс` описательна и не является SLO или
доказательством ускорения.
Эксперимент подключал только три акта в отдельном supplement и не доказывает
полноту всего корпуса, актуальность всех редакций, наличие вложений или
production-готовность. Отключение overlay/supplement скроет эти новые редакции
из локального поиска/API, но сохранит подготовленные файлы, review-записи и
исходный индекс. Production не менялся. Подробный технический отчёт и raw
benchmark остаются во внешнем рабочем наборе и в PR не включены.
## Пустые выдачи в лаборатории
Исходный контракт блокировал сохранение шести подписанных пустых снимков:
API отвергал `results: []`, а интерфейс читал поля из пустого placeholder.
Теперь signed snapshot может сохранить пустую оценку с обязательным общим
комментарием. В записи `top_result_code` остаётся `null`, список результатов
пустой. Непустой снимок по-прежнему обязан получить оценку каждого ранга ровно
один раз, совпадающую с подписанным кодом документа. HMAC, TTL и текущая
аутентификация не меняются.
Подписанный снимок теперь сохраняет scope запроса при явно настроенном
multi-index выражении (`A,B`): provenance не сужается до индекса, который
случайно вернул единственный результат, и сохраняется при пустой выдаче. Для
одного настроенного индекса или alias остаётся прежнее поведение: единственный
concrete `_index` записывается как provenance; если OpenSearch не сообщил его,
сохраняется настроенный scope. Новые отзывы получают эту метку при сохранении.
SQLite-схема, исторические записи и формат подписанных токенов не меняются.
Откат к старому коду не требует миграции и не переписывает существующие
отзывы; пока работает откатанная версия, новые multi-index отзывы снова могут
получить суженную метку, поэтому исправление provenance следует вернуть вместе
с основным выпуском.
Существующая отдельная база локальной лаборатории содержала 7 494 сохранённых
сессии, 79 AI-оценок и 55 516 диагностических комментариев; 74 715
оценок результатов остались пустыми намеренно. Integrity check прошёл,
порядок кодов и редакций baseline не менялся. Эти сохранённые оценки не
переписывались. В последующем изолированном trial шесть реальных подписанных
пустых отзывов с общими комментариями были сохранены как записи 7498–7503;
итоговые 7 503 строки лаборатории совпали с backup, integrity check прошёл.
Пустые query-level отзывы не являются оценками отдельных документов.
## Пробел актуального корпуса и безопасный контракт закрытия
Отдельно от ранжирования, в исходном base index не было трёх
подтверждённых действующих актов (все три canonical ID имели `_count = 0`):
| Акт | Canonical ID | Последняя проверенная редакция |
|---|---:|---|
| Трудовой кодекс КР от 23.01.2025 № 23 | 230021083 | 57548 от 28.07.2026 |
| Земельный кодекс КР от 18.07.2025 № 149 | 230029465 | 34746 от 18.07.2025 |
| Об охране здоровья граждан КР от 12.01.2024 № 14 | 230000918 | 47553 от 23.01.2026 |
Это разрыв между нормализованными документами и индексом, а не полное
отсутствие актов в локальном архиве: в `data/minjust-normalized` уже есть
canonical-каталоги всех трёх документов, manifest сообщает 209 958 успешно
обработанных документов. Однако трудовой акт там отстаёт от официальной
редакции 57548 от 28.07.2026 (последняя подготовленная — № 60 от 23.06.2026);
земельный акт нормализован до редакции № 10 от 18.07.2025; для акта о здоровье
последняя подготовленная — № 60 от 23.01.2026. В base index эти три canonical
ID не попали. Прежние отменённые трудовой и земельный кодексы нельзя переименовать
или поднять как замену актуальным нормам.
Для локальной изолированной проверки подготовлены точные официальные RU/KY
редакции: 3 документа и 8 934 фрагмента. На runtime 8081 они были временно
подключены отдельным supplement-index и доступны API через read-only overlay
catalogues. Это не изменение base index или production и не доказательство
полноты корпуса. Вложения и полная история новых canonical ID отдельно не
подтверждены.
Закрытие пробела требует отдельного контролируемого обновления данных:
1. Заново сверить официальные canonical ID, статус, edition ID и дату; сохранить
исходный ответ, хеши и provenance в отдельном staging-каталоге.
2. Проверить полноту обеих языковых версий, ссылки на редакции, историю и
вложения; не импортировать метаданные с ненужными персональными полями.
3. Нормализовать документы отдельно и проверить exact-count/hash. Для
ограниченного дополнения собрать отдельный версионный индекс только из этих
актов; не пересобирать и не перезаписывать индекс из 42,6 млн фрагментов.
4. Изолированный read-only trial уже проверил отдельный supplement-index,
подключённые каталоги `data_root`, RU/KY выдачу, current-edition routes,
collapse и контрольные запросы. Перед новым подключением нужно повторно
сверить official source и dataset guards; trial не записывал новые документы
в base index и не менял alias.
5. Публикация требует отдельного владельческого разрешения и проверенного
rollback. При отключении локального supplement-index поиск и просмотр новых
редакций из него прекращаются; подготовленные файлы и уже сохранённые отзывы
сохраняются, базовый индекс не удаляется. PR
фиксирует код и результаты локального испытания; production, base index и
исходные данные не менялись.
## Что остаётся проверить
- Финальный code/data эксперимент и full 7 500 run относятся к executable SHA
`1c7ddf2`; текущий follow-up commit меняет только этот report и changelog.
CI и полное независимое ревью должны повторно пройти на новом PR head.
- Browser fixture пустой выдачи прошёл на `726b1ee`; UI побайтно неизменён в
следующих commit. Для `1c7ddf2` выполнены реальные API/runtime smokes, но новый
полный browser run не заявляется.
- Три supplement-акта не подтверждают полноту всего корпуса, полную историю,
вложения, лицензирование или production readiness. Для новой публикации нужен
свежий source/edition check и отдельное разрешение владельца.
- Измерения top-k, AI-разметка и технические smoke checks не заменяют независимую
экспертную оценку юридической релевантности.
Исходные измерения и хеши хранятся вне репозитория в рабочем наборе
`search-benchmark-2026-11-02`. В этот репозиторий не включаются сырые ответы
MinJust, диагностические логи, внешние пользовательские данные или выгрузка
лабораторных комментариев.
@@ -0,0 +1,179 @@
# План внутреннего интерфейса оценки поисковой выдачи
## Цель
Создать закрытую лабораторию релевантности: юрист оценивает фактическую выдачу
нашего OpenSearch по практическим запросам. Цель — улучшать собственное
ранжирование, а не воспроизводить алгоритм сайта Минюста КР.
ЦБД Минюста используется как официальный источник текста, реквизитов, статуса
и редакции акта. Порядок результатов и оценка их полезности определяются в
нашем сервисе.
Это внутренний рабочий инструмент, а не публичный frontend MVP. Он не включает
регистрацию, личные кабинеты, публичный дизайн, сложные фильтры или сравнение
редакций.
## Сценарий юриста
1. Указать поисковый запрос и язык.
2. Получить первые 10–20 результатов в точном порядке OpenSearch.
3. Увидеть позицию каждого результата: `#1`, `#2` и далее.
4. Открыть выбранный документ по клику на название в правой панели страницы.
5. Поставить каждому просмотренному результату оценку и комментарий.
6. Сохранить снимок выдачи и оценок.
7. Передать накопленные записи на анализ ранжирования.
## Оценка результата
| Балл | Значение |
| ---: | --- |
| 0 | Нерелевантен: совпали слова, но акт не отвечает на вопрос. |
| 1 | Косвенно полезен: относится к теме, но прямого ответа нет. |
| 2 | Частично полезен: отвечает не полностью или требует другого акта. |
| 3 | Прямо и достаточно отвечает на запрос. |
Оценка относится к отдельному документу, а не ко всей выдаче. Результат без
оценки считается непросмотренным, а не нерелевантным.
## Интерфейс
Рекомендуемый экран — две панели.
- Вверху: поле запроса, переключатель RU/KY, выбор числа результатов и кнопка
«Найти».
- Слева: карточки результатов в порядке выдачи. Карточка содержит позицию,
название, тип, статус, дату, номер и фрагмент текста.
- Справа: заголовок, реквизиты, очищенный HTML выбранной редакции и ссылка на
официальный источник.
- В карточке: кнопки оценки `0`, `1`, `2`, `3` и раскрываемое поле
комментария.
- Внизу: имя или псевдоним проверяющего, общий комментарий и кнопка
«Сохранить оценку».
Правая панель предпочтительнее popup: она не блокируется браузером, сохраняет
контекст выдачи и работает на одном экране с оценкой.
### Доступность оценки и документа
Оценка реализуется нативной группой `radio` внутри `fieldset` с `legend`
«Оценка результата». У каждого значения есть видимая подпись: «0 —
нерелевантен», «1 — косвенно полезен», «2 — частично полезен», «3 — прямо
отвечает». Нельзя передавать смысл оценки только цветом. Все элементы управления
доступны с клавиатуры, имеют видимый `:focus-visible`; выбранный результат
обозначается текстом и визуальным состоянием.
На узком экране список результатов занимает всю страницу. Кнопка «Открыть
документ» открывает полноэкранный нативный `<dialog>` с явной кнопкой
«Закрыть». При закрытии фокус возвращается на исходную кнопку «Открыть
документ».
### Состояния
- Во время поиска и сохранения показывается состояние загрузки; повторная
отправка на это время недоступна. Стабильная пустая область `role="status"`
в DOM объявляет начало поиска, число результатов и успешное сохранение.
- Пустая выдача сообщает: «По запросу „…“ ничего не найдено» и предлагает
«Изменить запрос».
- Ошибка поиска сообщает причину и предлагает «Повторить поиск»; текст ошибки
выводится в `role="alert"`.
- Ошибка сохранения сообщает: «Не удалось сохранить. Проверьте подключение и
повторите». Черновик остаётся в браузере, текст ошибки выводится в
`role="alert"`.
- После сохранения выводится: «Оценка сохранена · № … · дата и время» в
указанной стабильной области `role="status"`.
До сохранения черновик хранится в `localStorage`. После успешного сохранения
интерфейс показывает ID записи и время сохранения.
## Backend и хранение
Использовать существующие endpoint:
- `GET /search` — получить ранжированный список результатов;
- `GET /documents/{code}/editions/{edition}` — получить текст выбранной
редакции.
Добавить два endpoint:
- `POST /search-reviews` — валидирует и сохраняет оценку;
- `GET /search-reviews/export` — отдаёт накопленные записи в JSON.
Для первой версии достаточно отдельной SQLite-базы. Это стандартная библиотека
Python, данные переживают перезапуск и легко выгружаются для анализа. Доступ к
интерфейсу и всем endpoint `search-reviews`, включая экспорт, должен быть
ограничен локальной сетью/VPN или аутентификацией reverse proxy.
Одна запись представляет один сохранённый поисковый сеанс:
| Поле | Назначение |
| --- | --- |
| `id`, `created_at` | Идентификатор и время сохранения. |
| `reviewer` | Имя или псевдоним проверяющего. |
| `query`, `language` | Исходный запрос и язык поиска. |
| `index_name`, `algorithm_version` | Версия индекса и алгоритма на момент оценки. |
| `top_result_code` | Код документа в позиции `#1`. |
| `results_json` | Снимок результатов в исходном порядке с оценками и комментариями. |
| `overall_comment` | Общий комментарий к выдаче. |
В `results_json` для каждого результата сохраняются: `rank`, `document_code`,
`edition_code`, название, реквизиты, фрагмент, оценка и комментарий. Снимок
выдачи обязателен: после изменения алгоритма можно будет восстановить именно
тот результат, который видел юрист.
## Правила сохранения
- Запрос не пустой, не длиннее 500 символов.
- Оценка может быть только целым числом от 0 до 3 либо отсутствовать у
непросмотренного результата.
- Комментарии имеют ограничение длины; пользовательские значения не вставляются
в HTML.
- `GET /search` возвращает краткоживущий HMAC-подписанный снимок выдачи:
запрос, язык, индекс, результаты и их позиции. `POST /search-reviews`
принимает этот снимок и только оценки с комментариями. Сервер проверяет
подпись и срок, самостоятельно формирует `results_json` и отклоняет оценки
для отсутствующих либо подменённых позиций и документов.
- Нельзя передавать персональные данные или закрытые материалы в комментариях.
- Кнопка «Сохранить оценку» остаётся доступной до отправки: отсутствующие
обязательные поля проверяются после нажатия, ошибка показана рядом с полем и
фокус переводится на первое некорректное поле.
## Анализ данных
Экспорт должен содержать исходный JSON-снимок, чтобы его можно было обработать
скриптом или открыть в табличном инструменте. Первый отчёт строит:
- среднюю оценку для каждой позиции выдачи;
- долю результатов с оценкой `3` в top-1, top-3 и top-10;
- запросы, где нет результатов с оценкой `2` или `3`;
- документы, которые часто получают низкую оценку в первых позициях;
- комментарии для ручного разбора ошибок.
Сырые оценки не должны автоматически менять веса поиска. Сначала команда
разбирает причины: анализатор, синонимы, статус, отсутствие документа,
неправильная формулировка запроса или юридическая неоднозначность.
## Этапы реализации
1. Утвердить шкалу 0–3, обязательность имени проверяющего и правила доступа.
2. Добавить SQLite-хранилище, валидацию, сохранение и JSON-экспорт.
3. Добавить статическую внутреннюю страницу в существующий Python-сервер, без
Next.js и отдельного публичного приложения.
4. Подключить поиск, правую панель документа, черновик, адаптивный режим и
сохранение в закреплённой панели действий на широком экране.
5. Добавить минимальные backend-проверки сохранения, повторного запуска,
экспорта и недопустимых оценок.
6. Провести ручный прогон на десяти русскоязычных практических запросах с
двумя юристами.
7. На собранных записях настроить OpenSearch и повторить тот же набор запросов.
## Критерий готовности
Юрист вводит запрос, видит порядок выдачи, открывает документ, выставляет
оценки и комментарии, сохраняет их. Экспорт содержит запрос, язык, документ
на позиции `#1`, полный порядок результатов, оценки, комментарии и версию
индекса. Данные можно сравнить до и после изменения алгоритма.
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.7.1 · Frontend — не создан
File diff suppressed because it is too large. Load diff
+100
View File
@@ -0,0 +1,100 @@
# Приёмка поискового экрана и лаборатории
Проверка выполнена 23 октября 2026 года в Chromium 156.0.8078.4, Playwright
1.64.1, headless, device scale factor 1. Макеты проверены при 1280×800,
1440×900, 1024×900 и 390×844 CSS px; reflow дополнительно проверен при
320×800 CSS px. Среда: Ubuntu 26.04 LTS под WSL2 Linux x86_64, Chromium
156.0.8078.4 headless, масштаб 100%, device scale factor 1.
## Снимки
- [1280×800 — основной экран](desktop-1280x800.png)
- [1440 px](desktop-1440px.png)
- [1024 px](desktop-1024px.png)
- [390 px — мобильный просмотр документа](mobile-390px.png)
- [Лаборатория `/review` — подсказка на desktop](review-tour-desktop.png)
- [Лаборатория `/review` — подсказка на mobile](review-tour-mobile.png)
- [Overlay 50% с приложенным референсом](overlay-50.png)
- [Карта абсолютной разницы пикселей](pixel-difference.png)
Покропленные сравнения областей для отдельного просмотра:
| Область | Overlay | Разница пикселей |
|---|---|---|
| Шапка и навигация | [Overlay](region-header-overlay.png) | [Diff](region-header-difference.png) |
| Фильтры | [Overlay](region-filters-overlay.png) | [Diff](region-filters-difference.png) |
| Результаты | [Overlay](region-results-overlay.png) | [Diff](region-results-difference.png) |
| Документ | [Overlay](region-document-overlay.png) | [Diff](region-document-difference.png) |
| Footer | [Overlay](region-footer-overlay.png) | [Diff](region-footer-difference.png) |
## Фактическая проверка
- Снимок референса 1586×992 пропорционально вписан в 1280×800. Границы
крупных зон расходятся с реализацией не более чем на 1 CSS px: колонки
x=0/226/648/1280; рабочая область начинается около y=126, заканчивается
на y=744; footer — y=744–800.
- Вторая строка навигации измерена по макету: поиск x=26–123, «Источники»
x=146–245, язык x=285–373, статус x=381–486, начало демо-подписи x=511.
Референсная подпись и элементы попадают в эти координаты с расхождением
около 1–2 px; у статуса остаётся несколько лишних пикселей по ширине.
- Горизонтального переполнения при 1440, 1024 и 390 px нет.
- При 320 px также нет горизонтального переполнения.
- Загружены Noto Sans и Noto Serif, включая кириллические наборы; повторяющихся
HTML id нет, ошибок JavaScript в сценарии не зарегистрировано.
- В fixture проверены ответы 200 для SVG и PNG favicon; локализация проверена
на фильтрах, результатах, счётчике совпадений и открытом статусе.
- Мобильная панель фильтров удерживает фокус в цикле, делает фон inert,
показывает 3 px focus ring, закрывается по Escape и возвращает фокус на
кнопку открытия.
- Playwright проверил сохранение второго фильтра при удалении первого, URL,
открытие редакции, поиск по документу
(1-е и 2-е совпадения), независимость языка текста от языка интерфейса,
отображение поисковых подписей и выбранного статуса на кыргызском,
разворачивание документа, действия профиля/пользователей/выхода, мобильную
панель и возврат к результатам, изоляцию `/review`, ошибки поиска и редакции,
повтор после ошибки и отсутствие доступных редакций.
- Проверены print CSS (служебные панели скрыты, документ и официальный источник
остаются), горизонтальная прокрутка широкой таблицы и изображения внутри
документа без общей прокрутки страницы, длинные названия, `prefers-reduced-motion`,
ошибки/повтор после ошибки, состояние без редакций, загрузка и сохранение
остальных URL-фильтров при удалении одного.
- Для фильтров и открытых `<dialog>` проверены Tab/Shift+Tab, Escape и возврат
фокуса. При Tab с последней кнопки в кабинете обнаружено и исправлено
выпадение фокуса на `body`; повторный прогон подтверждает цикл внутри диалога.
Полный скринридерный аудит этим не заменяется.
- Обучающая подсказка `/review` проверена на всех четырёх шагах при 1280×800
и 390×844: карточка не шире 320 px, использует Noto Sans/Noto Serif,
полупрозрачный фон и располагается у целевого элемента без пересечения.
Снимки первого шага сохранены для desktop и mobile.
Для снимков Playwright перехватывал API и отдавал локальные детерминированные
fixture-ответы. Подпись «Демонстрационные данные» добавлялась только в тестовой
странице перед снимком. Эти юридические тексты и результаты не входят в код
продукта. Для overlay и difference исходный растр 1586×992 вписывается
в 1280×800 с сохранением пропорций и полем около 1 px по бокам. В эталоне
нет кнопки кабинета; в интерфейсе она сохранена для доступности действий
учётной записи и остаётся заметным дополнительным элементом справа во второй
строке. Макет показывает язык поиска как удаляемый чип, интерфейс использует
доступный select; это функциональное расхождение пока оставлено явно видимым.
Содержимое документа, системный формат даты и сглаживание текста также не
совпадают попиксельно с концептом.
## Повторный запуск
Из корня репозитория выполните `cd tools/frontend-visual-review`, затем
`npm ci`, `npx playwright install chromium` и `npm test`. Скрипт читает текущий
`backend/search/review.html`, перехватывает API детерминированными fixture-
ответами и обновляет перечисленные выше снимки. Overlay и pixel-difference
создаются, если найден исходный файл по стандартному пути или передан путь в
переменной `REFERENCE_IMAGE`.
## Оставшиеся визуальные отличия
Сглаживание текста и содержимое документа отличаются от растрового макета.
Поля даты показывают системный формат Chromium. Переключатель языка интерфейса
и поле языка поиска остаются разными состояниями; в макете язык показан как
удаляемый чип. Дополнительная кнопка кабинета отсутствует в концепте. Не
проверялись полный набор ошибок авторизации, скринридер, видимый фокус на каждом
шаге, длинные реальные редакции и таблицы из корпуса. Автоматические снимки
показывают композицию, но не подтверждают юридическое содержимое или доступность
во всех браузерах.
Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Loaded 100 of 210 files, more files were not shown because too many files have changed in this diff. Show more