46 Commits
Author SHA1 Message Date
John Lancaster cf5d7d8c7b Merge branch 'main' into diverging 2026-06-26 18:35:27 -05:00
Jim Lancaster 939b0e46e9 Merge branch 'main' of https://gitea.john-stream.com/bbchops/transcription 2026-06-26 18:19:14 -05:00
Jim Lancaster 15af11ecb5 ver1 - Step6 Implementation Plan 2026-06-26 18:19:12 -05:00
John Lancaster 6dc58a8d50 ruff linting 2026-06-26 18:19:09 -05:00
Jim Lancaster e90dbe4958 ver1 - Step 5 implementation complete. 2026-06-26 17:51:02 -05:00
Jim Lancaster c9682b0399 ver1 - Step 5 implementation plan 2026-06-26 15:25:57 -05:00
Jim Lancaster dd80cd60cf ver1 - Step 4 implemented - Revision functionality added 2026-06-26 14:59:16 -05:00
Jim Lancaster 754a273ead Ver1 Step4 implementation plan 2026-06-26 14:37:30 -05:00
Jim Lancaster 06bb4290be Implement Ver1 Stage3 2026-06-26 14:23:12 -05:00
Jim Lancaster 21478a904c Revise step1 & step 2 2026-06-26 14:21:24 -05:00
Jim Lancaster b94d5d2863 Ver1.md updated to refocus on personal, smaller-scale deployment 2026-06-26 10:22:51 -05:00
John Lancaster 5ef74ef33a breaking up pages 2026-06-26 01:00:38 -05:00
John Lancaster e4889ba584 ruff and settings 2026-06-26 00:26:35 -05:00
Jim Lancaster f1758ca918 Added fix to db.py to accommodate new fields in db 2026-06-25 19:32:30 -05:00
Jim Lancaster e61f7e7518 ver1-step2 implemented 2026-06-25 19:22:44 -05:00
Jim Lancaster d69e0db4df Implemented v1 step1 2026-06-25 16:07:12 -05:00
Jim Lancaster 238875fc46 Ver1 Implementation Plan, and a detailed impl plan for step 1. 2026-06-25 14:35:20 -05:00
Jim Lancaster e291ffc907 Error handling added to MVP according to error_handling.md guideline 2026-06-25 12:56:35 -05:00
Jim Lancaster 0cc6b0e1eb error_handling.md added 2026-06-25 12:10:49 -05:00
Jim Lancaster 31ef94d4f5 Added usage instructions to README.md, minor fixes to upload_page.py 2026-06-25 11:46:03 -05:00
Jim Lancaster 643c523ed4 Step 6 implemented 2026-06-25 11:08:26 -05:00
Jim Lancaster 759c8c2739 Step 6 implementation plan 2026-06-25 10:28:43 -05:00
Jim Lancaster 3e057c0eff Step 5 implemented 2026-06-25 10:00:00 -05:00
John Lancaster abf5829c6b nicegui updates 2026-06-24 19:54:56 -05:00
Jim Lancaster 2cdba5f1d2 Step 5 implementation plan 2026-06-24 19:50:18 -05:00
Jim Lancaster fb1bc7ea16 Step 4 implemented 2026-06-24 19:12:42 -05:00
Jim Lancaster 3749355b19 Step 3 tested with real data. Added Step 4 implementation plan 2026-06-24 18:48:23 -05:00
Jim Lancaster 572a580445 Step 4 implementation plan 2026-06-24 17:58:08 -05:00
Jim Lancaster 865cca39e6 Step 3 complete 2026-06-24 17:33:51 -05:00
Jim Lancaster c6d95f5e73 Step 3 partially implemented. 2026-06-24 16:59:33 -05:00
John Lancaster 8c4a82ec35 testing improvements 2026-06-24 14:51:26 -05:00
Jim Lancaster d20b41aa88 Step 2 implemented 2026-06-24 14:02:36 -05:00
Jim Lancaster c1afcc4c9e Add step 2 detailed implementation plan 2026-06-24 12:54:55 -05:00
Jim Lancaster cba4d890a1 Step 1 complete! 2026-06-24 11:49:00 -05:00
Jim Lancaster 5165fa64bc Begin implementation of Step 1. Implementation interrupted when I ran out of credits at openrouter. Credits added. Now trying to figure out how to restart the process... 2026-06-24 11:09:02 -05:00
John Lancaster bf23893477 explicit resource links 2026-06-24 08:59:44 -05:00
John Lancaster 4f731cc293 reformed around openrouter for mvp 2026-06-23 20:23:28 -05:00
John Lancaster 0030c521d3 test enhancements 2026-06-23 20:08:02 -05:00
Jim Lancaster 4040332e8f Added Step 1 implentation plan 2026-06-23 19:53:43 -05:00
Jim Lancaster fdf931796e Change references to handwriting, expand options for LLMs 2026-06-23 19:13:24 -05:00
Jim Lancaster d0626c7653 Creation of MVP.md 2026-06-23 18:56:29 -05:00
Jim Lancaster 187324d903 Update docs 2026-06-23 10:49:11 -05:00
Jim Lancaster 2bb74cd1cc Initial setup continued 2026-06-22 17:32:18 -05:00
Jim Lancaster d0c5e3cb7a Add starting docs 2026-06-21 18:54:42 -05:00
Jim Lancaster 974e328150 Added .gitattributes 2026-06-21 17:47:47 -05:00
Jim Lancaster 506c697b63 Initial commit: initialized via uv 2026-06-21 17:43:55 -05:00
133 changed files with 13832 additions and 1118 deletions
+8
View File
@@ -0,0 +1,8 @@
PROVIDER=openrouter
OPENROUTER_API_KEY=sk-or-...
# PROVIDER_MODEL= # optional: OpenRouter adapter supplies default
# OPENROUTER_HTTP_REFERER=https://example.com
# OPENROUTER_APP_TITLE=Historical Transcription MVP
# DATABASE_URL=sqlite:///./transcription.db
# UPLOAD_DIR=./uploads
# PROMPT_DIR=./prompts
+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+16
View File
@@ -0,0 +1,16 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv
# Environment secrets
.env
# SQLite database
*.db
+1
View File
@@ -0,0 +1 @@
3.12
-232
View File
@@ -1,232 +0,0 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright © 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for software and other kinds of works.
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.
Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and modification follow.
TERMS AND CONDITIONS
0. Definitions.
“This License” refers to version 3 of the GNU General Public License.
“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations.
To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work.
A “covered work” means either the unmodified Program or a work based on the Program.
To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
1. Source Code.
The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.
A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
The Corresponding Source for a work in source code form is that same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
7. Additional Terms.
“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
11. Patents.
A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.
A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.
If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
python-template
Copyright (C) 2026 john
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
python-template Copyright (C) 2026 john
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”.
You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see <https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read <https://www.gnu.org/philosophy/why-not-lgpl.html>.
+115 -1
View File
@@ -1 +1,115 @@
# Python Template # Transcription
Historical document transcription system for family-history documents.
The app lets you upload a document image/PDF, queues a background transcription job, and then shows job status and results in a web UI.
## What the app does
- Upload document files (`.jpg`, `.jpeg`, `.png`, `.tif`, `.tiff`, `.pdf`)
- Persist document + job records in SQLite
- Process jobs in a background worker (`queued -> processing -> transcribed/failed`)
- Store transcript text (or failure detail)
- Show status and results in the NiceGUI interface
## Quick start
### 1) Install dependencies
```bash
uv sync
```
### 2) Configure environment
Create a `.env` file in the project root (minimum required setting shown):
```env
OPENROUTER_API_KEY=your_openrouter_api_key
```
Optional settings (defaults shown):
```env
DATABASE_URL=sqlite:///./transcription.db
UPLOAD_DIR=./uploads
PROMPT_DIR=./prompts
MAX_UPLOAD_BYTES=15728640
OPERATOR_ACCESS_ENABLED=false
OPERATOR_USERNAME=operator
# OPERATOR_PASSWORD=replace_with_secure_value
```
### 3) Run the app
```bash
uv run uvicorn transcription.app:create_app --factory --reload
```
### 4) (Optional) Run explicit migrations/checks
Use the migration runner for Step 4 schema safety workflows:
```bash
uv run python -m transcription.migration_runner --list
uv run python -m transcription.migration_runner --apply
uv run python -m transcription.migration_runner --check
```
### 5) Open in browser
- GUI: [http://[IP_ADDRESS]:8000/ui](http://[IP_ADDRESS]:8000/ui)
- Health check: [http://[IP_ADDRESS]:8000/healthz](http://[IP_ADDRESS]:8000/healthz)
### Schema safety settings
Optional environment settings (defaults shown):
```env
MIGRATION_AUTO_APPLY_ON_STARTUP=false
VALIDATE_SCHEMA_ON_STARTUP=true
```
### Step 5 security settings
Use this baseline for trusted private-network operation:
```env
OPERATOR_ACCESS_ENABLED=true
OPERATOR_USERNAME=operator
OPERATOR_PASSWORD=replace_with_strong_local_secret
MAX_UPLOAD_BYTES=15728640
```
Notes:
- `/healthz` remains unauthenticated for operational checks.
- `/ui` and `/api` require HTTP Basic credentials when operator access is enabled.
- Keep `OPERATOR_PASSWORD` in environment variables only (never commit secrets).
## How to navigate the GUI
- **Upload page** (`/ui`)
- Select a supported file to upload.
- The app creates a queued transcription job.
- Use the **View jobs** link to inspect progress.
- **Jobs page** (`/ui/jobs`)
- See all jobs and their status.
- Use **Refresh** to reload current states.
- Open a specific job to see details.
- **Job detail page** (`/ui/jobs/{job_id}`)
- Shows job metadata and status.
- Displays transcript text when successful.
- Displays failure detail when transcription fails.
## Prompt artifacts
Prompt files are stored in `prompts/` and loaded from `PROMPT_DIR` (default: `./prompts`).
The canonical MVP prompt is:
- `prompts/transcribe_document.md`
+40
View File
@@ -0,0 +1,40 @@
# Historical Document Transcription
I have several thousand pages of family history told through letters, postcards, books, and other documents that I want to transcribe to text.
## Goals
1. Preserve our family history
2. Unburden my family (and descendants) from having to store and care for the physical media. Once the documents have been transcribed and organized, they can be donated (or kept by a family member that wants to retain them).
3. Make the text easily available and searchable by family members, as well as AI (which may have different requirements).
4. Ability create timelines or assemble the historical record of the family or specific individuals from across the complete document archive. Perhaps use AI to create the timelines in a more narrative form.
## Source material
1. **letters, cards, diaries** - handwritten; mostly stored in tubs, with little organization
2. **books** - typed or typeset; mostly self-published books 50-100 pages in length. This may be expanded to include selected pages from other publications.
3. **newspaper clippings, event programs, invitations, and other ephemera**
## Methodology
### Verbatim vs. Clean Copy
Transcriptions should be Verbatim and follow scholarly research guidelines, with no modifications to the original text.
### Prompt Curation Policy
Transcription behavior should be implemented with prompt assets that are human-maintainable over time.
1. Each transcription prompt is stored as an individual Markdown file.
2. Prompt files are refined iteratively as document quality and edge cases are discovered.
3. Prompt changes should be scoped to one prompt file at a time whenever possible to keep review history clear.
### Potential Document Issues
| Document Issue | How to Handle It | Example |
| :--- | :--- | :--- |
| **Misspellings & Errors** | Retain original spelling and insert italicized `[sic]` directly after the error. | `The weather was very cold and publick [sic] business delayed.` |
| **Missing Words / Slips** | Insert the missing word inside square brackets to restore basic readability. | `We went [to] the store to buy supplies.` |
| **Uncertain / Guesswork** | Place your best hypothesis followed by a question mark inside square brackets. | `He went to [Boston?] yesterday to meet the governor.` |
| **Completely Illegible** | Use a clear descriptive term like `[illegible]` or specify the reason (e.g., `[torn]`, `[ink blot]`). | `The total cost was [illegible] dollars.` or `The letter ends here [remainder of page torn].` |
| **Crossed-out Text** | Wrap the removed word or phrase in a deleted tag to preserve the author's edits. | `We left at [deleted: noon] one o'clock instead.` |
| **Squeezed-in Text** | Wrap text that was added above the line or in a tight space in an inserted tag. | `The [inserted: red] house on the hill was abandoned.` |
| **Superscripts & Abbreviations** | Bring raised letters down to the main line, or optionally expand them in brackets. | `Change Gen^l to Genl` OR `Change to Gen[era]l depending on project preference.` |
| **Images / Seals / Signs** | Describe the non-textual element using italicized text inside square brackets. | `[wax notary seal attached here]` or `[sketch of a fort layout]` |
| **Marginalia / Notes** | Note the spatial transition clearly before transcribing the note itself. | `[written in left margin:] Do not share this with anyone.` |
| **Line Breaks / Hyphens** | Rejoin words split across a page margin silently, dropping the line-break hyphen. | `Original: "estab- / lishment" becomes "establishment"` |
| **Ambiguous Capitalization** | Default to modern capitalization rules unless an archaic uppercase letter is clearly intentional. | `If a standard noun like 'Farm' looks randomly capitalized, type 'farm'.` |
**Hierarchical Outlines** | Preserve exact numbering characters (including lowercase Roman numerals or terminal 'j'). Replicate indentation levels using spaces/tabs. Do not correct math or sequence errors silently. | `I. Main Topic`<br>`&nbsp;&nbsp;a. Sub-point`<br>`&nbsp;&nbsp;b. Next point`<br>`III. [sic] Third Topic` |
@@ -0,0 +1,35 @@
# ADR-0001: Lifespan-owned runtime resources
- **Status:** accepted
- **Date:** 2026-06-25
## Context
MVP initialized core runtime resources (database engine and worker dependencies) through module-level globals and startup side effects. `REQ-7` requires lifespan-owned runtime resources with explicit ownership and cleanup.
## Decision
Adopt lifespan-owned runtime resource initialization in `transcription.app`:
1. Initialize database runtime during app lifespan startup.
2. Store runtime handles on `app.state`.
3. Pass runtime-owned dependencies (engine) to worker startup.
4. Dispose runtime resources explicitly during lifespan shutdown.
## Consequences
### Positive
- Explicit startup and shutdown ownership.
- Predictable cleanup ordering.
- Reduced hidden global side effects.
### Tradeoffs
- Minor wiring complexity in app startup.
- Some call-sites still support fallback lazy initialization for compatibility.
## Alternatives Considered
1. **Keep module-level global ownership**
- Rejected: conflicts with `REQ-7` and increases ambiguity.
2. **Introduce full async DB stack immediately**
- Rejected for Step 1: too broad for architecture-consolidation scope.
@@ -0,0 +1,36 @@
# ADR-0002: Explicit schema bootstrap policy
- **Status:** accepted
- **Date:** 2026-06-25
## Context
MVP called schema bootstrap (`create_all`) on every startup. `REQ-10` requires explicit, opt-in schema bootstrap behavior so normal production startup does not mutate schema.
## Decision
Add environment-aware bootstrap policy:
1. New settings:
- `environment`: `development` | `test` | `production`
- `bootstrap_schema_on_startup`: optional explicit override
2. Default behavior:
- Development/test: bootstrap enabled
- Production: bootstrap disabled
3. App startup calls `create_all` only when policy evaluates true.
## Consequences
### Positive
- Production startup behavior is safer and policy-driven.
- Local development remains simple by default.
### Tradeoffs
- Deployments now require explicit schema management in production.
## Alternatives Considered
1. **Always bootstrap in all environments**
- Rejected: violates `REQ-10` intent.
2. **Disable bootstrap everywhere immediately**
- Rejected: hurts local developer workflow without migration tool replacement yet.
@@ -0,0 +1,31 @@
# ADR-0003: Persistence baseline and transition path
- **Status:** accepted
- **Date:** 2026-06-25
## Context
Architecture targets PostgreSQL baseline (optional MongoDB), while MVP currently runs on SQLite by default. V1 needs a clear transition path without destabilizing ongoing work.
## Decision
1. Preserve database URL configurability through centralized settings.
2. Keep SQLite functional for local dev/test and fast feedback.
3. Treat PostgreSQL as production baseline target for V1 completion.
4. Keep persistence access behind `transcription.db` runtime/session access points.
## Consequences
### Positive
- Clear migration path without immediate broad rewrite.
- Controlled risk while preserving velocity.
### Tradeoffs
- Temporary dual-path assumptions (SQLite local vs PostgreSQL target).
## Alternatives Considered
1. **Immediate forced PostgreSQL-only migration**
- Rejected: higher short-term disruption risk.
2. **Remain SQLite-only for V1**
- Rejected: inconsistent with architecture and requirement trajectory.
@@ -0,0 +1,32 @@
# ADR-0004: In-process worker topology for V1
- **Status:** accepted
- **Date:** 2026-06-25
## Context
The current system uses an in-process background worker. Architecture docs allow this in foundation stage and permit later hardening (optional external worker/queue).
## Decision
Retain in-process worker topology for V1, with improved lifecycle ownership:
1. Worker starts/stops via app lifespan.
2. Worker receives runtime-owned DB engine dependency explicitly.
3. Extension path to external worker remains behind existing service/adapter seams.
## Consequences
### Positive
- Keeps operational complexity low for personal-scale use.
- Preserves delivery focus on V1 completion.
### Tradeoffs
- Throughput/scaling limits remain compared to external queue-based topology.
## Alternatives Considered
1. **Immediate queue/external worker introduction**
- Rejected: premature complexity for current scale.
2. **Ad hoc thread lifecycle management outside lifespan**
- Rejected: weaker shutdown guarantees and poorer ownership clarity.
+20
View File
@@ -0,0 +1,20 @@
# Architecture Decision Records (ADRs)
This directory records significant architecture decisions for Version 1.
## ADR Format
Each ADR should include:
1. **Status** (`proposed`, `accepted`, `superseded`)
2. **Context**
3. **Decision**
4. **Consequences**
5. **Alternatives Considered**
## Index
- [ADR-0001: Lifespan-owned runtime resources](ADR-0001-lifespan-owned-runtime-resources.md)
- [ADR-0002: Explicit schema bootstrap policy](ADR-0002-explicit-schema-bootstrap-policy.md)
- [ADR-0003: Persistence baseline and transition path](ADR-0003-persistence-baseline-and-transition-path.md)
- [ADR-0004: In-process worker topology for V1](ADR-0004-in-process-worker-topology.md)
+294
View File
@@ -0,0 +1,294 @@
# Architecture
This document describes the production architecture of the personal historical-document transcription system. The system is intentionally optimized for single-user operation, low operational overhead, and clean internal boundaries that support future growth without rewrites.
## Architecture Objectives
The production architecture is designed to:
- preserve verbatim family-history source material as searchable text
- keep operational complexity low for a personal deployment
- support asynchronous transcription without requiring distributed infrastructure
- maintain clear module boundaries so extensions can be added incrementally
## Production Scope And Scale
The deployed system targets personal use and a corpus of several thousand documents processed over time. The architecture favors simple, composable building blocks over distributed orchestration.
Current scope includes:
- document upload and metadata capture
- asynchronous transcription jobs
- prompt-library driven transcription behavior, with one Markdown file per prompt
- transcript review and revision history
- full-text search over accepted transcripts
- export of transcript data
## Deployment Topology
The production deployment uses [Docker Compose](https://docs.docker.com/compose/) and treats containerized databases as extremely lightweight operational dependencies.
Running [PostgreSQL](https://www.postgresql.org/docs/) in its own container is considered simple by default for this system.
Running [MongoDB](https://www.mongodb.com/docs/) in its own container is also considered simple when document-centric storage is enabled.
Container count is not a hard architectural limit; a three-container deployment (app, PostgreSQL, MongoDB) is an acceptable baseline.
### Baseline Topology (Two Containers)
- one application container
- one PostgreSQL container
- embedded background worker execution inside the app process
### Expanded Topology (Three Containers)
- application container
- PostgreSQL container
- MongoDB container
No additional queue, scheduler, or search-engine containers are required in the baseline production setup.
## Runtime Architecture
```mermaid
flowchart LR
User[Browser User] --> App[FastAPI + NiceGUI Service]
App --> Worker[In-process Background Worker]
App --> PG[(PostgreSQL)]
App --> MG[(MongoDB Document Store)]
Worker --> AI[Transcription Provider]
Worker --> PG
Worker --> MG
```
## Runtime Ownership And Startup Policy (V1 Step 1)
The current implementation now uses explicit lifespan-owned runtime resources.
- application lifespan initializes and disposes database runtime resources
- worker lifecycle is owned by application lifespan startup/shutdown
- worker receives lifespan-owned database engine dependency explicitly
- schema bootstrap policy is environment-aware and explicit:
- development/test default to bootstrap enabled
- production defaults to bootstrap disabled
- explicit override is available via configuration
This aligns implementation toward REQ-7 and REQ-10 while preserving personal-scale operational simplicity.
## Layered Module Structure
### Interface Layer
Responsibility:
- HTTP API and UI routes
- request/response validation
- status and result presentation
Out of scope:
- business-rule enforcement
- data-access implementation
### Application Layer
Responsibility:
- upload and job orchestration
- state transitions and retry policy
- coordination across domain and infrastructure ports
Out of scope:
- provider-specific protocol details
- ORM or storage-specific logic
### Domain Layer
Responsibility:
- verbatim transcription policy
- revision and provenance invariants
- confidence and annotation semantics
Out of scope:
- web framework concerns
- database and network I/O
### Infrastructure Layer
Responsibility:
- persistence adapters (PostgreSQL and MongoDB)
- transcription-provider adapter
Out of scope:
- business policy decisions
## Processing Workflow
Production transcription flow:
1. A user uploads an image or PDF through the UI or API.
2. The application validates payloads and creates document and job records.
3. The in-process worker dequeues the job and calls the transcription provider.
4. The application persists transcript output, confidence metadata, and provenance events.
5. Job status transitions from queued to processing to transcribed or failed.
6. The UI and API expose status, revision history, and searchable transcript text.
## Data Model Ownership
System-of-record entities:
- documents and pages
- transcription jobs and status events
- transcript revisions
- provenance metadata
Storage strategy:
- PostgreSQL for relational system-of-record entities
- MongoDB for document-oriented payloads and large transcription artifacts
- versioned prompt artifacts stored as individual Markdown files for human editing and refinement
- in-memory execution state treated as ephemeral
## Transcription Prompt Asset Policy
The production system treats transcription prompts as maintainable content assets.
- each transcription prompt is stored in its own Markdown file
- prompt files are designed for direct human editing and iterative refinement
- prompt updates are independent and do not require bundling unrelated prompt changes
- prompt file identity and revision history are tracked through normal repository version control
## Simplicity Guardrails
The production system enforces these constraints to prevent accidental over-engineering:
- PostgreSQL in a container is treated as a lightweight default dependency
- MongoDB in a container is treated as a lightweight optional dependency
- three containers (app, PostgreSQL, MongoDB) is an acceptable simple deployment
- no dedicated queue or search cluster is introduced without measured need
- external infrastructure is added only behind existing ports/adapters
## Extension Path
The architecture supports additive growth without changing domain contracts.
### Stage 1: Foundation (Current)
- upload, transcription, review, search, export
- in-process worker execution
- single provider adapter
- app plus PostgreSQL deployment
### Stage 2: Throughput Hardening
- optional MongoDB document-store enablement
- optional external worker/queue process
- stronger retry and dead-letter handling
### Stage 3: Intelligence Features
- entity extraction and cross-document linking
- timeline and narrative assembly
- optional multi-provider routing
Each stage preserves existing module boundaries and keeps migration risk low.
## Test Strategy
The test strategy is aligned to personal-scale operation with fast, deterministic feedback.
### Unit Tests
- domain transcription rules and annotation behavior
- revision-history invariants
- job state-transition logic
### Integration Tests
- repository behavior and transaction boundaries
- persistence-adapter and provider adapter contract mapping
- upload-to-persistence roundtrip
### End-to-End Tests
- happy path: upload, transcribe, review, search, export
- failure path: provider error, retry, surfaced failed status
### CI Execution Model
- fast suite on each push
- optional slower provider-sandbox checks on scheduled runs
## Risks And Controls
### Runtime Responsiveness
Risk:
- long jobs can reduce responsiveness in a single-process deployment
Control:
- bounded concurrency and visible job status in the UI
### Database Concurrency Limits
Risk:
- contention can appear under sustained concurrent writes in personal-scale infrastructure
Control:
- tuned connection pooling and phased use of MongoDB for document-heavy workloads
### Provider Output Variance
Risk:
- transcription quality varies by document type, handwriting legibility, and image quality
Control:
- first-class human review and immutable revision history
## Technology References
- [FastAPI documentation](https://fastapi.tiangolo.com/)
- [NiceGUI documentation](https://nicegui.io/documentation)
- [Docker Compose documentation](https://docs.docker.com/compose/)
- [PostgreSQL documentation](https://www.postgresql.org/docs/)
- [MongoDB documentation](https://www.mongodb.com/docs/)
## Related Pages
- [System overview](index.md)
- [Version 1 plan](ver1/ver1.md)
- [Version 1 Step 1 plan](ver1/ver1-step1.md)
- [Version 1 Step 1 results](ver1/ver1-step1-results.md)
- [Architecture decision records index](adr/README.md)
## Glossary
- Adapter: A component that translates between internal interfaces and external systems such as databases or AI services.
- Background job: Work executed outside the request/response path so the UI remains responsive.
- Boundary: A strict separation between modules with different responsibilities.
- CI (Continuous Integration): Automated test execution for code changes.
- Contract test: A test that verifies an adapter follows expected input/output behavior at a boundary.
- Domain layer: The module that contains core business rules and invariants.
- End-to-end test: A test that validates a full user flow across the running system.
- Full-text search: Text indexing and querying optimized for natural-language search.
- In-process worker: A background executor that runs within the same application process.
- Integration test: A test that verifies interactions between real modules and infrastructure components.
- MongoDB: A document-oriented database used for flexible, high-variance data structures.
- Modular monolith: A single deployable application with strongly separated internal modules.
- Port/Interface: A stable contract used by application/domain code to call infrastructure implementations.
- Prompt artifact: A single Markdown file that defines one transcription prompt and can be revised independently.
- Provenance: Metadata that records where generated data came from and how it was produced.
- Revision history: Versioned record of transcript edits over time.
- System of record: The authoritative persistent store for canonical data.
- Vertical slice: A minimal end-to-end feature path spanning UI/API, application logic, and persistence.
+282
View File
@@ -0,0 +1,282 @@
# Error Handling
This document defines the canonical error-handling policy for the document transcription system. It is the single source of truth for how errors are classified, surfaced to users, logged for diagnosis, and handled across UI, API, service, worker, and provider boundaries.
## Error Handling Objectives
The production error-handling model is designed to:
- make failures visible to the user in clear, actionable language
- preserve enough diagnostic detail for fast troubleshooting
- keep module behavior consistent across all boundaries
- distinguish expected domain failures from unexpected defects
- support safe retries for transient failures without hiding persistent faults
## Scope And Authority
This page governs error-handling behavior for:
- UI interactions (NiceGUI pages)
- API endpoints (FastAPI routes)
- application services and orchestration logic
- in-process background worker execution
- external provider adapters and persistence adapters
If implementation behavior conflicts with this document, this document is authoritative and implementation should be updated.
## Core Principles
- **Clarity first:** user-facing messages should explain what failed in plain language.
- **Actionability required:** each surfaced error should include a suggested next step.
- **Safety by default:** internal details are logged; sensitive details are not exposed by default in UI/API.
- **Consistency across boundaries:** category and structure should remain stable from source to surface.
- **Fail explicitly:** silent failure is prohibited.
- **Traceability:** every non-trivial error should be traceable with an error reference ID.
## Error Taxonomy
The system uses stable, implementation-independent categories:
| Category | Definition | Typical Source | Retriable |
| --- | --- | --- | --- |
| `validation_error` | Payload or parameter shape/content is invalid | UI/API input validation, service guards | no |
| `user_input_error` | User-provided artifact is unacceptable though structurally valid | unsupported file type, empty file, oversized upload | sometimes |
| `not_found_error` | Requested resource does not exist | missing job/document/transcript | no |
| `conflict_error` | Requested operation violates current state constraints | invalid state transition | no |
| `external_provider_error` | External AI/provider call fails | upstream HTTP/API/provider failures | sometimes |
| `infrastructure_transient_error` | Temporary environment issue | network timeout, DB connection reset | yes |
| `infrastructure_persistent_error` | Non-transient environment issue | missing permissions, misconfiguration | no |
| `internal_unexpected_error` | Unhandled defect or unknown failure | uncaught exceptions, logic errors | unknown (default no) |
### Classification Rules
- Classification occurs as close as possible to the origin boundary.
- Provider-specific exceptions must be normalized into taxonomy categories before crossing service boundaries.
- Unknown exceptions are classified as `internal_unexpected_error` and logged with traceback.
- Category names are stable contracts and must not be changed casually.
## User-Facing Error Experience Contract
When an error is shown in the GUI, it must include:
1. **Title** (short context, e.g., “Upload failed”)
2. **Message** (plain-language explanation)
3. **Suggested action** (explicit next step)
4. **Error reference ID** (for support/debug traceability)
5. **Technical details** (optional/collapsible for advanced users)
### UI Message Rules
- Do not expose raw stack traces by default.
- Do not expose secrets, credentials, connection strings, or filesystem internals unless explicitly in debug tooling.
- Prefer domain language over implementation language.
- Use persistent visibility for important failures (dialog/card), not only transient toasts.
### Suggested Action Requirements
Every user-visible error must include a suggested course of action, such as:
- retry the operation
- check file type/size constraints
- refresh the jobs page
- verify environment configuration
- contact operator with error ID and timestamp
## API Error Response Contract
API errors should return a structured envelope with stable fields:
- `error_id`: short unique reference ID
- `category`: taxonomy category
- `message`: safe human-readable summary
- `suggestion`: recommended next step
- `details`: optional, only when safe and appropriate
- `timestamp`: UTC ISO-8601
HTTP status mapping guidance:
- `validation_error`, `user_input_error` -> `400`
- `not_found_error` -> `404`
- `conflict_error` -> `409`
- `external_provider_error` -> `502` or `503` (depending on failure mode)
- `infrastructure_transient_error` -> `503`
- `infrastructure_persistent_error` -> `500`
- `internal_unexpected_error` -> `500`
## Logging And Observability Contract
All logged errors must include, where available:
- `error_id`
- `category`
- `operation` (e.g., `upload.submit`, `worker.process_job`, `jobs.refresh`)
- `exception_type`
- `job_id`, `document_id` (when relevant)
- UTC timestamp
Rules:
- Use structured logging fields where practical.
- Use full traceback for unexpected errors (`internal_unexpected_error`).
- Log at boundary handoff points to preserve causal trail.
- Avoid duplicate noisy logging for the same exception at every layer.
## Recovery And Retry Policy
### Retriable Conditions
Retriable failures include:
- transient network/provider timeouts
- intermittent provider unavailability
- temporary DB/network interruptions
### Non-Retriable Conditions
Non-retriable failures include:
- invalid file formats
- missing required data
- permission/configuration failures
- deterministic domain conflicts
### Worker Behavior
- The worker must classify and persist failure details consistently.
- Retries should be bounded by configured limits.
- Exhausted retries must end in explicit failed status with recorded reason.
- No infinite retry loops are allowed.
## Boundary-Specific Responsibilities
### UI Layer
Responsibility:
- display user-safe error summaries and suggested actions
- show persistent error visibility for critical failures
- include error reference IDs in visible output
Out of scope:
- low-level exception parsing
- provider-specific protocol interpretation
### API Layer
Responsibility:
- map application exceptions into stable error envelopes and HTTP statuses
- preserve category and error_id continuity
Out of scope:
- domain-specific remediation logic
### Service Layer
Responsibility:
- classify domain and infrastructure exceptions
- convert adapter-specific failures into taxonomy categories
- return deterministic error types to callers
Out of scope:
- presentation formatting for UI
### Worker Layer
Responsibility:
- execute retry policy for retriable failures
- persist terminal failure details for jobs
- emit operational logs with category and identifiers
Out of scope:
- direct UI messaging
### Provider Adapter Layer
Responsibility:
- normalize provider SDK/HTTP failures into domain-neutral exceptions
- preserve raw provider context for logs (safely)
Out of scope:
- choosing user-facing wording
## Error Lifecycle Workflow
Standard lifecycle:
1. Failure occurs at a boundary or operation.
2. Exception is classified into taxonomy category.
3. `error_id` is created (or propagated).
4. Error is logged with required structured fields.
5. User/API receives safe message + suggested action.
6. Persistent job/resource state is updated when applicable.
7. Tests verify contract behavior for the pathway.
## Test Strategy For Error Handling
### Unit Tests
- category classification behavior
- retry eligibility decisions
- exception-to-message mapping safety
### Integration Tests
- UI pathways show clear message + suggested action for known failures
- API returns structured error envelope with expected status/category
- worker persists failed status and failure detail as required
### Regression Tests
- each previously observed production issue should have a guarding test
- contract tests must cover adapter error normalization behavior
## Known Failure Patterns And Prescribed Responses
| Pattern | Category | User Message | Suggested Action |
| --- | --- | --- | --- |
| Upload payload cannot be parsed by UI handler | `internal_unexpected_error` (until narrowed) | Upload failed due to unexpected processing error | Retry once; if repeated, report error ID and check runtime version compatibility |
| Unsupported extension | `user_input_error` | File type is not supported | Upload JPG, PNG, TIFF, or PDF |
| Empty file upload | `validation_error` | Uploaded file is empty | Choose a valid non-empty file and retry |
| Provider timeout | `external_provider_error` or `infrastructure_transient_error` | Transcription provider timed out | Retry from jobs page; if repeated, check provider status |
| Job lookup missing | `not_found_error` | Requested job was not found | Refresh jobs list and open a valid job |
## Governance And Update Process
This document is a living policy artifact.
Update this document when:
- new error categories are introduced
- handling behavior changes at any boundary
- a production incident reveals missing guidance
- API/UI error contracts change
Change requirements:
- update this document and associated tests in the same change set
- preserve taxonomy stability; if changed, document migration impact
- record noteworthy policy changes in project release notes or changelog
## Related Pages
- [System overview](index.md)
- [Architecture](architecture.md)
- [Requirements](requirements.md)
- [Intent](intent.md)
## Glossary
- Error category: Stable classification used to drive handling, messaging, and status mapping.
- Error envelope: Structured API payload describing a failure.
- Error reference ID: Short identifier used to correlate user-visible failure with logs.
- Retriable error: Failure likely to succeed on a later attempt without code changes.
- Terminal failure: Failure state after retries are exhausted or retry is not allowed.
+55
View File
@@ -0,0 +1,55 @@
## Document Transcription System
This project is a production application for transcribing and preserving historical family documents. It is intentionally designed for personal-scale use, with a simplicity-first architecture that is easy to operate and easy to extend.
## Start Here
Read [architecture.md](architecture.md) first.
Then review [ver1/ver1.md](ver1/ver1.md) for completion scope.
The architecture page is the primary technical reference and defines:
- deployed topology and infrastructure limits
- module boundaries and dependency flow
- processing life cycle and data ownership
- test strategy, risk controls, and extension path
## What The Application Does
At a high level, users upload images of handwritten, typed, or typeset documents, run asynchronous transcription jobs, review and edit transcript revisions, and search across accepted text.
Core capabilities:
- document upload and metadata capture
- asynchronous transcription with visible job status
- transcription prompt management with one Markdown file per prompt for human refinement over time
- revision history for transcript edits
- full-text search over accepted transcripts
- export of transcript data
## Production Operating Model
The system runs with minimal operational overhead:
- PostgreSQL in a dedicated Docker container is considered extremely lightweight and simple for this system
- MongoDB in a dedicated Docker container is also considered extremely lightweight and simple for document-centric persistence
- a three-container deployment (app, PostgreSQL, MongoDB) is a simple and acceptable baseline
- no required queue or search-engine containers in the baseline setup
This operating model keeps deployment and maintenance simple while preserving clean boundaries for future scale.
## Documentation Map
- Version 1 implementation plan: [ver1/ver1.md](ver1/ver1.md)
- Architecture and technical design: [architecture.md](architecture.md)
- Architecture decision records (ADR index): [adr/README.md](adr/README.md)
- Runtime and deployment requirements: [requirements.md](requirements.md)
- Error handling policy and operational guidance: [error_handling.md](error_handling.md)
- Domain context and transcription policy: [intent.md](intent.md)
## Glossary
- Document-oriented persistence: Storing data as flexible records instead of fixed relational rows.
- Prompt artifact: A single Markdown file that defines one transcription prompt and is edited independently.
- System of record: The authoritative persistent store for canonical data.
+572
View File
@@ -0,0 +1,572 @@
# Step 1 Implementation Plan: `config.py` + `models.py` + `db.py`
## Purpose
Establish the foundational data layer and configuration system that every subsequent MVP step builds on. At the end of this step, the project has a runnable Python package with a validated schema, typed configuration, and a test suite proving the data layer works — before any UI, worker, or AI provider code exists.
---
## 1. Prerequisite: Project Structure Scaffolding
Before writing any logic, create the package skeleton so imports work correctly.
### Files to create (empty `__init__.py` stubs)
```
src/
└── transcription/
├── __init__.py
├── providers/
│ └── __init__.py
├── services/
│ └── __init__.py
└── ui/
└── __init__.py
```
### Files to create (with logic — the Step 1 deliverables)
```
src/transcription/config.py
src/transcription/models.py
src/transcription/db.py
```
### Test files to create
```
tests/
├── __init__.py
├── conftest.py
├── test_config.py
├── test_models.py
└── test_db.py
```
### Update `pyproject.toml`
Add the dependencies that Step 1 requires and won't change later:
```toml pyproject.toml
[project]
name = "transcription"
version = "0.1.0"
description = "Historical document transcription system"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"openrouter>=0.7.0",
"pydantic>=2.13.4",
"pydantic-settings>=2.9.1",
"sqlmodel>=0.0.25",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.25",
]
[tool.pytest.ini_options]
addopts = "--strict-markers -q"
markers = [
"unit: pure logic tests with no external dependencies",
"integration: tests that touch framework or database contracts",
"external: tests that call external services (slow, requires credentials)",
]
```
Key additions:
- **`openrouter`** — official OpenRouter Python SDK used for model calls
- **`pydantic-settings`** — for `BaseSettings` with env-var loading (this was split out of `pydantic` core in v2)
- **`sqlmodel`** — provides SQLModel (which bundles SQLAlchemy + Pydantic model integration) and the SQLite driver
- **`pytest` + `pytest-asyncio`** — in `dev` extras for test execution
- **`[tool.pytest.ini_options]`** — strict marker checking enabled from the start; markers registered upfront per pytesting skill conventions
### Delete `hello.py`
The placeholder file is no longer needed.
---
## 2. `config.py` — Centralized Configuration
**Satisfies:** REQ-8 (centralized config and logging at startup)
### Design Decisions
| Decision | Rationale |
|----------|-----------|
| Use `pydantic-settings` `BaseSettings` | Type-safe, validates on construction, loads from env vars and `.env` files automatically |
| `PROVIDER` constrained to `openrouter` for MVP | Keeps configuration explicit while avoiding premature multi-provider complexity |
| `OPENROUTER_API_KEY` required | Matches official SDK docs and avoids ambiguous provider-agnostic naming |
| `PROVIDER_MODEL` defaults to `None` | OpenRouter adapter (Step 3) supplies a sensible default when `None` |
| `OPENROUTER_HTTP_REFERER` and `OPENROUTER_APP_TITLE` optional | Matches SDK optional app-attribution fields |
| `DATABASE_URL` defaults to SQLite | Zero-setup local development; PostgreSQL swap is a single env-var change post-MVP |
| `UPLOAD_DIR` and `PROMPT_DIR` as `Path` objects | Enables `.mkdir(parents=True, exist_ok=True)` and path validation at startup |
| Logging configured via `logging.config.dictConfig` in `setup_logging()` | Centralized, explicit formatter/handler/root logger topology; called once at startup with `disable_existing_loggers=False` |
### Proposed Implementation
```python src/transcription/config.py
"""Centralized application configuration.
All settings are loaded from environment variables (or a .env file)
once at startup. Provider-specific defaults (model names, base URLs)
are resolved by the provider adapters, not here.
"""
from enum import StrEnum
from functools import lru_cache
from pathlib import Path
import logging
import logging.config
from pydantic_settings import BaseSettings, SettingsConfigDict
class Provider(StrEnum):
OPENROUTER = "openrouter"
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
# --- AI provider ---
provider: Provider = Provider.OPENROUTER
openrouter_api_key: str
provider_model: str | None = None
openrouter_http_referer: str | None = None
openrouter_app_title: str | None = None
# --- persistence ---
database_url: str = "sqlite:///./transcription.db"
# --- filesystem paths ---
upload_dir: Path = Path("./uploads")
prompt_dir: Path = Path("./prompts")
LOGGING_CONFIG: dict[str, object] = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"standard": {
"format": "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
"datefmt": "%Y-%m-%d %H:%M:%S",
}
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "standard",
"stream": "ext://sys.stdout",
}
},
"root": {
"level": "INFO",
"handlers": ["console"],
},
}
@lru_cache(maxsize=1)
def get_settings() -> Settings:
"""Return the singleton Settings instance.
Cached so the entire application shares one validated config.
"""
return Settings()
def setup_logging() -> None:
"""Configure root logging once at startup."""
logging.config.dictConfig(LOGGING_CONFIG)
```
### Key Behaviors
- **Startup validation**: If `OPENROUTER_API_KEY` is missing from the environment, `Settings()` raises a `ValidationError` immediately — the app won't start with a missing key.
- **`.env` support**: Developers can create a `.env` file in the project root for local keys; it's never committed (already covered by the existing `.gitignore` pattern or a new entry).
- **`extra="ignore"`**: Unknown env vars don't cause errors, keeping the config resilient to unrelated environment variables.
- **`lru_cache`**: `get_settings()` is the single access point. All modules import and call this function rather than constructing `Settings` directly.
- **Centralized logging**: `setup_logging()` calls `dictConfig` exactly once at startup; all modules should use `logging.getLogger(__name__)` and avoid `basicConfig`.
### `.env` template (not committed — add to `.gitignore`)
```bash .env.example
PROVIDER=openrouter
OPENROUTER_API_KEY=sk-or-...
# PROVIDER_MODEL= # optional: OpenRouter adapter supplies default
# OPENROUTER_HTTP_REFERER=https://example.com
# OPENROUTER_APP_TITLE=Historical Transcription MVP
# DATABASE_URL=sqlite:///./transcription.db
# UPLOAD_DIR=./uploads
# PROMPT_DIR=./prompts
```
### `.gitignore` addition
```gitignore .gitignore
# ... existing entries ...
# Environment secrets
.env
```
---
## 3. `models.py` — SQLModel Domain Models
**Satisfies:** REQ-3 (persist and expose job states), REQ-4 (persist transcription output and failure details)
### Design Decisions
| Decision | Rationale |
|----------|-----------|
| Three models: `Document`, `Job`, `Transcript` | Minimal set from MVP Feature 5. One-to-many from Document→Job and one-to-one from Job→Transcript |
| `JobStatus` as a `StrEnum` | Readable in the database (`"queued"` not `1`), type-safe in Python, trivially serializable to JSON for the UI |
| Status values: `queued`, `processing`, `transcribed`, `failed` | Matches MVP Feature 2 lifecycle. REQ-3 also lists `upload` and `completed` — these are deferred to post-MVP when revision/review workflows exist |
| UUIDs for primary keys | Avoids auto-increment collision concerns if we later move to PostgreSQL; safe for distributed ID generation; `uuid4` is simple |
| `uploaded_at`, `created_at`, `updated_at` as UTC `datetime` | Timezone-naive UTC by convention for MVP. Sufficient for single-user, single-timezone operation |
| `Transcript.text` is nullable | A failed job creates a Transcript with `text=None` and `error_detail` populated, keeping the query model uniform |
| Relationships via SQLModel `Relationship` | Enables `document.jobs` and `job.transcript` navigation in service code without manual joins |
### Proposed Implementation
- `resource://skills/fastapi-async-sqlalchemy-modernization/document`
```python src/transcription/models.py
"""SQLModel domain models for the transcription system.
Three models capture the MVP lifecycle:
Document → one-to-many → Job → one-to-one → Transcript
"""
from datetime import datetime, timezone
from enum import StrEnum
from uuid import UUID, uuid4
from sqlmodel import Field, Relationship, SQLModel
class JobStatus(StrEnum):
QUEUED = "queued"
PROCESSING = "processing"
TRANSCRIBED = "transcribed"
FAILED = "failed"
class Document(SQLModel, table=True):
"""An uploaded document image."""
id: UUID = Field(default_factory=uuid4, primary_key=True)
filename: str
file_path: str
uploaded_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
)
# --- relationships ---
jobs: list["Job"] = Relationship(back_populates="document")
class Job(SQLModel, table=True):
"""A transcription job tied to a single document."""
id: UUID = Field(default_factory=uuid4, primary_key=True)
document_id: UUID = Field(foreign_key="document.id")
status: JobStatus = Field(default=JobStatus.QUEUED)
created_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
)
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
)
# --- relationships ---
document: Document = Relationship(back_populates="jobs")
transcript: "Transcript | None" = Relationship(back_populates="job")
class Transcript(SQLModel, table=True):
"""The output of a transcription job."""
id: UUID = Field(default_factory=uuid4, primary_key=True)
job_id: UUID = Field(foreign_key="job.id", unique=True)
text: str | None = None
error_detail: str | None = None
created_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
)
# --- relationships ---
job: Job = Relationship(back_populates="transcript")
```
### Entity-Relationship Summary
```
┌──────────┐ ┌──────────┐ ┌─────────────┐
│ Document │ 1───* │ Job │ 1───1 │ Transcript │
├──────────┤ ├──────────┤ ├─────────────┤
│ id (PK) │ │ id (PK) │ │ id (PK) │
│ filename │ │ doc_id │──FK──▶│ job_id (FK) │
│ file_path│ │ status │ │ text │
│ uploaded │ │ created │ │ error_detail│
│ │ │ updated │ │ created │
└──────────┘ └──────────┘ └─────────────┘
```
### Why Only Four Status Values
REQ-3 lists six states: `upload`, `queued`, `processing`, `transcribed`, `failed`, `completed`. The MVP simplifies this:
| REQ-3 State | MVP Treatment |
|-------------|---------------|
| `upload` | Implicit — the Document record exists before a Job is created. No separate job state needed. |
| `queued` | ✅ Included — job created, waiting for worker pickup |
| `processing` | ✅ Included — worker is actively transcribing |
| `transcribed` | ✅ Included — AI output received and stored |
| `failed` | ✅ Included — error captured |
| `completed` | Deferred — implies human review/acceptance. In MVP, `transcribed` is the terminal success state. |
---
## 4. `db.py` — Database Engine and Session Management
**Satisfies:** MVP Feature 5 (SQLite auto-created on first startup)
### Design Decisions
| Decision | Rationale |
|----------|-----------|
| Module-level `create_engine` + `Session` factory | REQ-7 (lifespan-owned resources) is deferred. A module-level engine is adequate for MVP's single-process, single-user operation |
| `create_all()` as an explicit function | Called at app startup. MVP auto-creates tables (REQ-10 deferred), but the function is isolated so it's easy to gate behind a flag later |
| `get_session()` as a generator | Standard FastAPI/SQLModel pattern — yields a session, ensures cleanup. Compatible with `Depends()` when the API layer arrives in Step 5 |
| `echo=False` default | Keeps logs clean. Can be toggled for debugging |
### Proposed Implementation
```python src/transcription/db.py
"""Database engine, session factory, and schema bootstrap.
MVP uses SQLite with auto-create-tables at startup.
PostgreSQL migration is a post-MVP configuration change.
"""
import contextlib
from collections.abc import Generator
from sqlmodel import Session, SQLModel, create_engine
from transcription.config import get_settings
def _build_engine():
settings = get_settings()
connect_args = {}
if settings.database_url.startswith("sqlite"):
connect_args["check_same_thread"] = False
return create_engine(
settings.database_url,
echo=False,
connect_args=connect_args,
)
engine = _build_engine()
def create_all() -> None:
"""Create all tables. Called once at application startup."""
SQLModel.metadata.create_all(engine)
@contextlib.contextmanager
def get_session() -> Generator[Session]:
"""Yield a database session and ensure cleanup."""
with Session(engine) as session:
yield session
```
### SQLite-Specific Note
`check_same_thread=False` is required for SQLite when the session may be accessed from different threads (e.g., a background worker on a different thread than the request handler). This setting is harmless and ignored for PostgreSQL connection strings.
---
## 5. Test Plan
Refer to these resources for rules and guidelines about structure:
- `resource://skills/pytesting/document`
- `resource://catalog/prompts/pytest-scaffold`
- `resource://catalog/prompts/pytest-fill-scaffold`
Hierarchy pattern used in this step:
```text
tests/
conftest.py
test_config.py
TestSettingsLoading
test_loads_from_env
test_requires_api_key
TestProviderSettings
test_defaults_to_openrouter
test_rejects_invalid_value
test_optional_fields_default_to_none
TestPathSettings
test_path_fields_are_path_objects
test_models.py
TestDocumentModel
test_can_be_persisted
test_defaults_are_populated
TestJobModel
test_can_be_created_for_document
test_defaults_are_populated
test_transitions_to_transcribed
test_transitions_to_failed
TestTranscriptModel
test_success_record_persists
test_failure_record_persists
test_job_id_is_unique
TestRelationships
test_document_exposes_jobs
test_job_exposes_transcript
test_db.py
TestSchemaBootstrap
test_create_all_creates_expected_tables
TestSessionFactory
test_get_session_yields_session
test_session_is_closed_after_generator_exit
```
### `tests/conftest.py` — Shared Fixtures
```python tests/conftest.py
"""Shared test fixtures.
Every test gets a fresh in-memory SQLite database so tests are
isolated, fast, and leave no artifacts on disk.
"""
import pytest
from sqlmodel import Session, SQLModel, create_engine
from sqlmodel.pool import StaticPool
@pytest.fixture
def session():
"""Provide a clean database session for each test."""
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
SQLModel.metadata.create_all(engine)
with Session(engine) as session:
yield session
```
`StaticPool` ensures a single in-memory SQLite connection is shared across threads, which is required when `TestClient` (Step 5) spawns threads that would otherwise get separate in-memory databases. Establishing it now keeps the fixture stable across all future steps.
### `tests/test_config.py` — Configuration Hierarchy
| Class | Method | What It Verifies |
|------|--------|------------------|
| `TestSettingsLoading` | `test_loads_from_env` | `Settings` constructs successfully when `OPENROUTER_API_KEY` is set via env var |
| `TestSettingsLoading` | `test_requires_api_key` | `Settings()` raises `ValidationError` when `OPENROUTER_API_KEY` is missing |
| `TestProviderSettings` | `test_defaults_to_openrouter` | Default provider is `openrouter` when not explicitly set |
| `TestProviderSettings` | `test_rejects_invalid_value` | Setting `PROVIDER=invalid` raises `ValidationError` |
| `TestProviderSettings` | `test_optional_fields_default_to_none` | `provider_model`, `openrouter_http_referer`, and `openrouter_app_title` are `None` when unset |
| `TestPathSettings` | `test_path_fields_are_path_objects` | `upload_dir` and `prompt_dir` are `Path` instances |
### `tests/test_models.py` — Model & Relationship Hierarchy
| Class | Method | What It Verifies |
|------|--------|------------------|
| `TestDocumentModel` | `test_can_be_persisted` | A `Document` can be persisted and read back with correct fields |
| `TestDocumentModel` | `test_defaults_are_populated` | `id` is auto-generated UUID, `uploaded_at` is populated |
| `TestJobModel` | `test_can_be_created_for_document` | A `Job` linked to a `Document` via FK persists correctly |
| `TestJobModel` | `test_defaults_are_populated` | Default status is `queued`, `created_at` and `updated_at` are populated |
| `TestJobModel` | `test_transitions_to_transcribed` | Status can be updated from `queued` → `processing` → `transcribed` |
| `TestJobModel` | `test_transitions_to_failed` | Status can be updated from `processing` → `failed` |
| `TestTranscriptModel` | `test_success_record_persists` | A `Transcript` with `text` set and `error_detail=None` persists correctly |
| `TestTranscriptModel` | `test_failure_record_persists` | A `Transcript` with `text=None` and `error_detail` set persists correctly |
| `TestRelationships` | `test_document_exposes_jobs` | `document.jobs` returns the linked `Job` list |
| `TestRelationships` | `test_job_exposes_transcript` | `job.transcript` returns the linked `Transcript` |
| `TestTranscriptModel` | `test_job_id_is_unique` | Inserting two transcripts with the same `job_id` raises an integrity error |
### `tests/test_db.py` — Database Bootstrap Hierarchy
| Class | Method | What It Verifies |
|------|--------|------------------|
| `TestSchemaBootstrap` | `test_create_all_creates_expected_tables` | After `create_all()`, the expected tables (`document`, `job`, `transcript`) exist in the database |
| `TestSessionFactory` | `test_get_session_yields_session` | `get_session()` yields a usable `Session` object |
| `TestSessionFactory` | `test_session_is_closed_after_generator_exit` | After the generator is exhausted, the session is closed |
### Marker Strategy (Step 1)
- Markers (`unit`, `integration`, `external`) are registered upfront in `pyproject.toml` with `--strict-markers` enabled, per pytesting skill conventions.
- All Step 1 tests are unmarked — they run in the default lane since they are fast, deterministic, and have no external dependencies.
- When slower integration or external tests are introduced in later steps, apply explicit markers and keep test names unchanged.
### Test Workflow
Follow the two-phase approach from `resource://catalog/prompts/pytest-scaffold` and `resource://catalog/prompts/pytest-fill-scaffold`:
1. **Scaffold phase**: Create test files with class hierarchy, method names, and one-line docstrings only. Validate collection:
- `uv run pytest --collect-only -q`
2. **Fill phase**: Implement assertions, fixtures, and minimal test data. Treat scaffolded names and docstrings as locked. Validate execution:
- `uv run pytest -q`
Scaffolded structure is treated as a stable baseline — do not rename, move, merge, split, or re-nest tests once the scaffold is reviewed.
---
## 6. Step 1 Completion Checklist
When all of the following are true, Step 1 is done and Step 2 can begin:
| # | Criterion | How to Verify |
|---|-----------|---------------|
| 1 | `src/transcription/` package exists with `config.py`, `models.py`, `db.py` | `ls` / file inspection |
| 2 | Empty `__init__.py` stubs exist for `providers/`, `services/`, `ui/` | `ls` / file inspection |
| 3 | `Settings` loads from environment and validates `OPENROUTER_API_KEY` is present | `test_config.py` passes |
| 4 | `Document`, `Job`, `Transcript` models create tables in SQLite | `test_models.py` passes |
| 5 | `JobStatus` enum has exactly four values: `queued`, `processing`, `transcribed`, `failed` | `test_models.py` passes |
| 6 | Foreign key relationships work: Document→Job→Transcript | `test_models.py` passes |
| 7 | `create_all()` bootstraps the schema; `get_session()` yields a working session | `test_db.py` passes |
| 8 | All tests pass: `uv run pytest -q` | CI / local run |
| 9 | `hello.py` is deleted | File inspection |
| 10 | `pyproject.toml` includes `openrouter`, `sqlmodel`, `pydantic-settings`, `pytest`, `pytest-asyncio` | File inspection |
| 10a | `pyproject.toml` has `[tool.pytest.ini_options]` with `--strict-markers` and registered markers | File inspection |
| 11 | `.env.example` documents all config vars; `.env` is in `.gitignore` | File inspection |
| 12 | `setup_logging()` uses `logging.config.dictConfig` with centralized formatter/handler/root config | File inspection |
| 13 | `uv run pytest --collect-only -q` shows expected test hierarchy | Local run |
| 14 | `uv run pytest -q` passes all tests | Local run |
---
## 7. What This Step Does NOT Include
Explicitly out of scope to prevent scope creep:
| Excluded | Reason |
|----------|--------|
| FastAPI / NiceGUI app entrypoint | Step 5 |
| Additional provider adapters beyond OpenRouter | Post-MVP |
| Upload service logic | Step 4 |
| Worker / background processing | Step 4 |
| Transcription prompt files | Step 2 |
| Alembic or migration tooling | Post-MVP (REQ-10 deferred) |
| Async session factory | Post-MVP (REQ-7 deferred) |
---
This plan produces a fully tested, importable data foundation. Every subsequent step imports from `transcription.config`, `transcription.models`, and `transcription.db` without modification.
+278
View File
@@ -0,0 +1,278 @@
## Step 2: prompts/transcribe_document.md
### Goal
Implement the MVP prompt artifact system by creating a curated transcription prompt file:
- `prompts/transcribe_document.md`
This step primarily satisfies:
- **REQ-12**: prompts stored as individual Markdown artifacts
- MVP Feature 3: prompt-driven verbatim transcription behavior grounded in `docs/intent.md`
---
## Scope for Step 2
### In scope
1. Create prompt artifact directory and first prompt file.
2. Encode transcription rules from `docs/intent.md` into a model-facing prompt.
3. Define stable prompt structure so future revisions are easy to diff/review.
4. Add lightweight tests that validate artifact presence and baseline quality constraints.
5. Update docs/README references so Step 3 can consume prompt file directly.
### Out of scope
- Provider integration logic (Step 3)
- Worker/job orchestration (Step 4)
- UI behavior (Step 5)
---
## Proposed Deliverables
1. **`prompts/transcribe_document.md`**
- production prompt text for historical document transcription
2. **`prompts/README.md`** (recommended)
- conventions for prompt files, revision policy, naming
3. **`tests/test_prompts.py`** (recommended)
- artifact existence + structure checks
4. **Small docs update** (README or docs reference)
- indicate that prompts are file-based and loaded from `PROMPT_DIR`
---
## Detailed Work Breakdown
### 1) Create prompt artifact folder and canonical file
- Add `prompts/` at repo root.
- Add `transcribe_document.md` as the first curated artifact.
- Keep filename stable; this becomes the default in Step 3 unless overridden.
### 2) Author prompt content using a strict, sectioned format
Use section headers so future diffs are clean and policy changes are isolated.
Suggested sections:
1. **Purpose**
- verbatim scholarly transcription of historical documents
2. **Output requirements**
- plain text only
- no summaries, no paraphrasing
- preserve reading order and meaningful structure
3. **Core fidelity rules**
- preserve original wording and punctuation
- dont silently normalize grammar/spelling
- no invented content
4. **Issue-handling rules (mapped from Intent table)**
- misspellings with `[sic]`
- missing words with `[word]`
- uncertainty with `[guess?]`
- illegible with `[illegible]` / reason tags
- crossed-out text as `[deleted: ...]`
- inserted text as `[inserted: ...]`
- superscripts handling guidance
- non-text elements as `[description]`
- marginalia format `[written in left margin: ...]`
- line-break hyphen rejoin behavior
- capitalization policy
- hierarchical outline preservation (including unusual numbering)
5. **Confidence/ambiguity policy**
- prefer explicit uncertainty markers over hallucination
6. **Final self-checklist for model**
- did I preserve structure?
- did I mark uncertain text?
- did I avoid silent corrections?
### 3) Add prompt-library conventions (`prompts/README.md`)
Recommended conventions:
- one prompt per file
- snake_case names
- each file starts with purpose + behavior contract
- iterative edits, one prompt per PR where possible
- no secrets in prompt files
### 4) Add tests for prompt assets (`tests/test_prompts.py`)
Keep tests robust but not brittle.
Recommended tests:
1. `test_prompt_file_exists`
2. `test_prompt_file_is_not_empty`
3. `test_prompt_mentions_verbatim_behavior`
4. `test_prompt_includes_uncertainty_and_illegible_markers`
5. `test_prompt_includes_deleted_and_inserted_conventions`
Avoid exact full-text matching; verify key semantic anchors only.
### 5) Optional config alignment check
Current config already has:
- `prompt_dir: Path = Path("./prompts")`
In Step 2, ensure docs reflect this and that Step 3 will resolve:
- `PROMPT_DIR / "transcribe_document.md"`
---
## Task-by-Task Execution Checklist
## Phase A — Scaffold files
- [ ] **A1. Create prompt directory**
- Path: `prompts/`
- Verify: directory exists at repo root
- [ ] **A2. Create canonical prompt file**
- Path: `prompts/transcribe_document.md`
- Verify: file exists and is non-empty
- [ ] **A3. (Recommended) Create prompt library README**
- Path: `prompts/README.md`
- Verify: includes naming + revision conventions
---
## Phase B — Author prompt content (core work)
- [ ] **B1. Add Purpose section**
- States verbatim historical transcription objective
- Explicitly disallows summarization/paraphrase
- [ ] **B2. Add Output Contract section**
- Plain text output expectation
- Preserve meaningful structure and reading order
- No fabricated text
- [ ] **B3. Add Rule Set from `docs/intent.md`**
- Misspellings/errors: `[sic]`
- Missing words: `[word]`
- Uncertain readings: `[guess?]`
- Illegible regions: `[illegible]` / reason labels
- Crossed-out text: `[deleted: ...]`
- Squeezed-in text: `[inserted: ...]`
- Superscripts/abbrev handling guidance
- Non-text visuals: bracketed descriptive labels
- Marginalia formatting cue
- Rejoin line-break hyphenated words silently
- Ambiguous capitalization policy
- Hierarchical outline numbering preservation
- [ ] **B4. Add Ambiguity and Confidence policy**
- “Mark uncertainty instead of guessing”
- “Never silently normalize uncertain passages”
- [ ] **B5. Add Final Self-Check section**
- Checklist for fidelity, uncertainty labeling, and format compliance
---
## Phase C — Add validations (tests)
- [ ] **C1. Create prompt tests file**
- Path: `tests/test_prompts.py`
- [ ] **C2. Add existence/health checks**
- Prompt file exists
- Prompt file has content (non-whitespace)
- [ ] **C3. Add semantic anchor checks**
- Mentions verbatim behavior
- Mentions uncertainty marker pattern (`?` in brackets conceptually)
- Mentions illegible handling
- Mentions deleted/inserted conventions
- [ ] **C4. Keep tests resilient**
- Avoid exact full-file snapshot assertions
- Assert required concepts, not precise phrasing
---
## Phase D — Documentation alignment
- [ ] **D1. Update top-level docs/README reference**
- Mention that prompts live in `prompts/`
- Mention Step 3 loads from `PROMPT_DIR`
- [ ] **D2. Confirm config compatibility**
- `src/transcription/config.py` already uses `prompt_dir = Path("./prompts")`
- No code change needed unless naming/path mismatch appears
---
## Phase E — Verification
- [ ] **E1. Run targeted test file**
- `uv run pytest tests/test_prompts.py -q`
- [ ] **E2. Run full suite**
- `uv run pytest -q`
- [ ] **E3. Confirm no regressions**
- All existing tests still green (expected: previous 20 + new prompt tests)
---
## Phase F — Commit plan (recommended granularity)
- [ ] **F1. Commit 1: scaffold**
- `prompts/transcribe_document.md` (initial structure)
- `prompts/README.md` (if included)
- [ ] **F2. Commit 2: finalized prompt content**
- full rule-complete prompt text
- [ ] **F3. Commit 3: tests + docs alignment**
- `tests/test_prompts.py`
- README/docs mention of prompt artifact pattern
---
## Done Criteria (quick gate)
- [ ] Canonical prompt exists and is curated for verbatim transcription.
- [ ] Prompt encodes all high-value handling rules from `docs/intent.md`.
- [ ] Prompt tests pass.
- [ ] Full project tests pass with `uv`.
- [ ] Ready for Step 3 provider integration.
---
## Acceptance Criteria (Definition of Done)
Step 2 is complete when all are true:
1. `prompts/transcribe_document.md` exists and is committed.
2. Prompt includes all critical handling rules from `docs/intent.md`.
3. Prompt is structured with stable section headings for future curation.
4. Prompt tests pass under `uv run pytest -q`.
5. Existing tests remain green (total suite still passes).
6. Docs indicate prompt artifact location and curation policy.
---
## Risks and Mitigations
1. **Risk: prompt too vague → hallucinated reconstructions**
- Mitigation: explicit uncertainty/illegible conventions and “no invention” rule.
2. **Risk: prompt too rigid for mixed document types**
- Mitigation: include neutral defaults + clear annotation formats.
3. **Risk: brittle tests block iterative prompt tuning**
- Mitigation: test semantic anchors, not exact wording.
---
## Handoff to Step 3
After Step 2, Step 3 can immediately:
1. Load `transcribe_document.md` from `PROMPT_DIR`
2. Inject prompt into OpenRouter request
3. Start validating real transcription behavior with minimal glue code
+236
View File
@@ -0,0 +1,236 @@
## Step 3: services/transcription.py + providers/
### Objective
Implement the **AI transcription integration layer** so the app can:
1. Read the curated prompt from `PROMPT_DIR`
2. Send prompt + image to the configured provider (OpenRouter)
3. Return normalized transcription output (or structured failure)
This corresponds to MVP Step 3 from `docs/mvp.md`:
- `services/transcription.py`
- `providers/` adapter(s)
---
## Scope for Step 3
### In scope
- Provider abstraction and OpenRouter adapter
- Prompt file loading utility in service layer
- Image payload preparation
- One high-level transcription service function usable by Step 4 worker
- Unit tests (mocked provider SDK, no external calls)
### Out of scope
- Job polling/background loop (Step 4)
- DB status transition orchestration in worker loop (Step 4)
- UI invocation/wiring (Step 5)
---
## Planned Deliverables
### Source files
- `src/transcription/providers/base.py`
- `src/transcription/providers/openrouter.py`
- `src/transcription/providers/__init__.py` (exports + factory)
- `src/transcription/services/transcription.py`
- `src/transcription/services/__init__.py` (optional export)
### Tests
- `tests/providers/test_openrouter.py`
- `tests/services/test_transcription.py`
### Test directory convention
- Mirror source domains under `tests/`.
- Provider adapter tests live under `tests/providers/`.
- Service-layer tests live under `tests/services/`.
- Prefer one focused test module per production module (for Step 3: `test_openrouter.py`, `test_transcription.py`).
---
## Design Decisions (before coding)
1. **Provider interface first**
- Define a stable contract independent of SDK specifics.
- Prevent Step 4 from depending on raw SDK response shapes.
2. **Service returns normalized result object**
- Include: `text`, `provider`, `model`, `raw_error`/exception metadata.
- Worker can map this cleanly to `Transcript` and `JobStatus`.
3. **Prompt loaded from file at call time**
- Uses `get_settings().prompt_dir / "transcribe_document.md"`.
- Keeps prompt edits hot-swappable without code changes.
4. **Clear exception boundary**
- SDK/network/model failures become predictable domain exceptions:
- `ProviderError`
- `PromptLoadError`
- `TranscriptionError` (optional top-level wrapper)
5. **Model resolution policy**
- Use `settings.provider_model` if set
- Otherwise use adapter default constant (e.g., vision-capable model slug)
---
## Task-by-Task Execution Checklist
## Phase A — Provider contract
- [ ] Create `src/transcription/providers/base.py`
- [ ] Define protocol/ABC for transcription providers:
- [ ] method signature accepts prompt text + image bytes (or data URL) + mime type
- [ ] returns normalized text result (and optional metadata)
- [ ] Define shared provider exceptions:
- [ ] `ProviderError`
- [ ] optional subclasses (`ProviderAuthError`, `ProviderResponseError`)
---
## Phase B — OpenRouter adapter
- [ ] Create `src/transcription/providers/openrouter.py`
- [ ] Implement `OpenRouterTranscriptionProvider` with:
- [ ] config-driven API key usage
- [ ] optional referer/title attribution headers
- [ ] model resolution fallback when `provider_model` is unset
- [ ] Implement request building:
- [ ] prompt included as instruction content
- [ ] image included in supported format for vision call
- [ ] Implement response parsing:
- [ ] extract final transcript text from SDK response
- [ ] validate non-empty text
- [ ] Wrap SDK failures into `ProviderError` with clean message
---
## Phase C — Provider factory
- [ ] Update `src/transcription/providers/__init__.py`
- [ ] Add `get_transcription_provider()` factory:
- [ ] reads `settings.provider`
- [ ] returns OpenRouter adapter for `openrouter`
- [ ] raises explicit error for unsupported provider values
---
## Phase D — Transcription service (Step 3 core)
- [ ] Create `src/transcription/services/transcription.py`
- [ ] Add prompt loader function:
- [ ] default file: `transcribe_document.md`
- [ ] raises `PromptLoadError` on missing/empty file
- [ ] Add image loader/validator:
- [ ] path existence check
- [ ] allowed mime detection (`.jpg/.jpeg/.png/.tiff/.pdf` policy aligned to MVP)
- [ ] Add high-level function (name example):
- [ ] `transcribe_document_image(image_path, prompt_name="transcribe_document.md")`
- [ ] loads prompt + image
- [ ] calls provider from factory
- [ ] returns normalized transcription result object
- [ ] Add structured logging at key boundaries:
- [ ] prompt loaded
- [ ] provider invoked
- [ ] success/failure outcome (no sensitive data in logs)
---
## Phase E — Tests (two-phase scaffold -> fill)
### Required execution resources
Load and reference these directly during test planning/implementation so the two-phase flow is enforced:
- [ ] `resource://catalog/prompts/pytest-scaffold`
- [ ] `resource://prompts/pytest-scaffold/document`
- [ ] `resource://catalog/prompts/pytest-fill-scaffold`
- [ ] `resource://prompts/pytest-fill-scaffold/document`
### Phase E1 — Scaffold test structure first
Prompt: `resource://catalog/prompts/pytest-scaffold`
Suggested arguments:
- [ ] `target_modules` = `src/transcription/providers/openrouter.py`, `src/transcription/services/transcription.py`
- [ ] `mode` = `scaffold`
- [ ] `path_strategy` = `src-to-tests-mirror`
- [ ] `naming_style` = `concise-behavior`
Expected scaffold outcomes:
- [ ] `tests/providers/test_openrouter.py` exists with class/method skeletons and one-line docstrings
- [ ] `tests/services/test_transcription.py` exists with class/method skeletons and one-line docstrings
- [ ] collection succeeds on scaffold-only tests
Scaffold coverage targets:
- [ ] adapter initializes from settings
- [ ] model fallback when `provider_model is None`
- [ ] referer/title options included when set
- [ ] successful SDK response parses transcript text
- [ ] SDK exception maps to `ProviderError`
- [ ] empty/invalid response maps to `ProviderError`
- [ ] prompt loader reads canonical prompt file
- [ ] missing prompt raises `PromptLoadError`
- [ ] transcription function loads file and calls provider once
- [ ] image path missing raises clear error
- [ ] provider error is propagated/wrapped predictably
- [ ] returned result includes transcript text and metadata
### Phase E2 — Fill scaffolded tests with assertions
Prompt: `resource://catalog/prompts/pytest-fill-scaffold`
Suggested arguments:
- [ ] `target_files` = `tests/providers/test_openrouter.py`, `tests/services/test_transcription.py`
- [ ] `stack` = `pure-python`
- [ ] `strategy` = `minimal`
- [ ] `marker_lane` = `unit`
Fill constraints:
- [ ] preserve scaffold class/method names and one-line docstrings
- [ ] keep mocks to an absolute minimum; mock only network boundaries and non-deterministic failures
- [ ] keep one behavior target per test method
> Default suite should remain deterministic and fast, but mocking should be minimal and intentional.
### Optional real-endpoint validation lane
- [ ] Add an opt-in integration lane for real provider calls (for example `@pytest.mark.integration` and `@pytest.mark.live_api`).
- [ ] Gate live tests behind explicit env vars (for example `OPENROUTER_API_KEY`, optional `RUN_LIVE_API_TESTS=1`).
- [ ] Exclude live tests from default CI/local runs unless explicitly requested.
- [ ] Keep at least one thin smoke path that can validate request/response compatibility against the real endpoint.
---
## Phase F — Verification commands
- [ ] E1 scaffold validation: `uv run pytest --collect-only -q`
- [ ] E2 fill validation (unit lane): `uv run pytest -m unit -q`
- [ ] E2 targeted provider file: `uv run pytest tests/providers/test_openrouter.py -q`
- [ ] E2 targeted service file: `uv run pytest tests/services/test_transcription.py -q`
- [ ] E2 final full-suite check: `uv run pytest -q`
---
## Implementation Notes / Guardrails
- Avoid coupling Step 3 service to DB models directly (that belongs in Step 4 orchestration).
- Do not silently swallow provider errors.
- Keep prompt filename stable (`transcribe_document.md`) unless explicitly parameterized.
- Keep request/response normalization inside provider adapter, not worker/UI layers.
---
## Definition of Done (Step 3)
Step 3 is done when:
1. Provider abstraction exists and OpenRouter adapter is implemented.
2. Service can transcribe a local image using prompt file content.
3. Failures are returned as structured exceptions, not raw SDK traceback noise.
4. Unit tests for provider and service pass.
5. Full suite remains green under `uv run pytest -q`.
6. Step 4 can call a single service function to process queued jobs.
+262
View File
@@ -0,0 +1,262 @@
## Step 4: `services/upload.py` + `worker.py`
### Objective
Implement the MVP upload and background-processing pipeline so the system can:
1. Save uploaded files into `UPLOAD_DIR`
2. Create `Document` + `Job(status="queued")`
3. Process queued jobs in a worker loop:
- `queued -> processing`
- call Step 3 transcription service
- persist `Transcript`
- finalize as `transcribed` or `failed`
This step advances MVP Feature 1 + Feature 2 and supports REQ-1, REQ-2, REQ-3, REQ-4, REQ-6.
---
## Scope
### In scope
- `src/transcription/services/upload.py`
- `src/transcription/worker.py`
- Upload persistence logic and initial job creation
- Worker polling and single-job lifecycle execution
- Deterministic test coverage for upload + worker (default suite)
### Out of scope
- UI integration and pages (Step 5)
- Queue infrastructure beyond in-process loop
- Async DB/session architecture refactor
- Broad production hardening beyond MVP needs
---
## Planned Deliverables
### Source files
- `src/transcription/services/upload.py`
- `src/transcription/worker.py`
- `src/transcription/services/__init__.py` (export updates as needed)
### Test files
- `tests/services/test_upload.py`
- `tests/services/test_worker.py`
### Optional external lane (already present pattern)
- reuse `external` marker for live-provider checks where appropriate
- keep external out of default lane
---
## Required MCP Prompt References (for test workflow)
Apply these resources directly during Step 4 test creation:
1. `resource://catalog/prompts/pytest-scaffold`
2. `resource://prompts/pytest-scaffold/document`
3. `resource://catalog/prompts/pytest-fill-scaffold`
4. `resource://prompts/pytest-fill-scaffold/document`
And (as referenced by those prompts) apply relevant pytest skill references for:
- naming/hierarchy
- marker defaults
- SQLAlchemy sync testing behavior where applicable
---
## Design Decisions
1. **Upload service owns initial file + record creation**
- Writes file, creates `Document`, creates queued `Job`, returns IDs/path.
2. **Worker owns lifecycle transitions**
- Worker is the single owner of `queued -> processing -> terminal` job state changes.
3. **Worker uses Step 3 service boundary**
- Worker calls `transcribe_document_image(...)`; no provider-specific SDK logic in worker.
4. **Failure information is always persisted**
- On failure: store `Transcript(text=None, error_detail=...)` and set `Job.status=failed`.
5. **Loop remains simple and stoppable**
- In-process polling loop with stop event and poll interval for MVP simplicity and testability.
---
## Task-by-Task Execution Checklist
## Phase A — Implement upload service (`src/transcription/services/upload.py`)
- [ ] Create `UploadError` exception
- [ ] Create `UploadJobResult` dataclass with:
- [ ] `document_id`
- [ ] `job_id`
- [ ] `stored_path`
- [ ] `original_filename`
- [ ] Add filename safety handling:
- [ ] normalize to basename
- [ ] avoid path traversal
- [ ] collision-safe stored name (e.g., UUID prefix/suffix)
- [ ] Validate upload payload:
- [ ] non-empty bytes required
- [ ] extension in supported set (`.jpg/.jpeg/.png/.tif/.tiff/.pdf`)
- [ ] Ensure upload directory exists (`mkdir(parents=True, exist_ok=True)`)
- [ ] Write file bytes to `UPLOAD_DIR`
- [ ] Persist DB records in one transaction:
- [ ] `Document(filename, file_path)`
- [ ] `Job(document_id=..., status=queued)`
- [ ] Return `UploadJobResult`
- [ ] Add logging for success/failure boundaries
---
## Phase B — Implement worker core (`src/transcription/worker.py`)
- [ ] Add `process_next_queued_job(...) -> bool`
- [ ] Fetch oldest queued job
- [ ] Return `False` when no queued jobs exist
- [ ] Transition picked job to `processing` and update timestamp
- [ ] Resolve associated `Document.file_path`
- [ ] Call `transcribe_document_image(image_path=...)`
- [ ] On success:
- [ ] insert/update transcript text
- [ ] clear error detail
- [ ] mark job `transcribed`
- [ ] update timestamp
- [ ] On failure:
- [ ] insert/update transcript with `text=None`, `error_detail=...`
- [ ] mark job `failed`
- [ ] update timestamp
- [ ] Commit terminal state and return `True`
- [ ] Add logs around job pickup, transition, and terminal outcome
---
## Phase C — Implement worker loop (`src/transcription/worker.py`)
- [ ] Add `run_worker_loop(...)`
- [ ] Accept configurable stop event/signal
- [ ] Accept configurable poll interval
- [ ] Repeatedly call `process_next_queued_job`
- [ ] Sleep only when queue is empty
- [ ] Exit cleanly when stop event is set
---
## Phase D — Exports
- [ ] Update `src/transcription/services/__init__.py` to expose upload APIs
- [ ] Keep existing transcription exports intact
---
## Phase E — Tests via MCP scaffold -> fill flow
## E1 Scaffold (structure only)
Use scaffold prompt workflow first for:
- `src/transcription/services/upload.py`
- `src/transcription/worker.py`
Expected scaffold targets:
- `tests/services/test_upload.py`
- `tests/services/test_worker.py`
Scaffold rules:
- [ ] Class hierarchy + method names + one-line docstrings only
- [ ] No assertions or implementation details in scaffold phase
- [ ] Keep method names concise and behavior-focused
Validation:
- [ ] `uv run pytest --collect-only -q`
## E2 Fill scaffold (implementation)
Use fill prompt workflow for:
- `tests/services/test_upload.py`
- `tests/services/test_worker.py`
- stack: `sqlalchemy-sync` (or `mixed` if combining pure + DB behaviors)
- marker lane preference: `unit` and `integration` as appropriate
- strategy: minimal deterministic implementation
Fill rules (invariants):
- [ ] Preserve scaffold class names, method names, and one-line docstrings
- [ ] Do not rename/re-nest scaffolded tests unless explicitly approved
- [ ] One behavior target per test
- [ ] Minimal mocking; mock only network/nondeterministic boundaries
Suggested test coverage:
### `tests/services/test_upload.py`
- [ ] creates file + document + queued job (`integration`)
- [ ] rejects empty bytes (`unit`)
- [ ] rejects unsupported extension (`unit`)
- [ ] writes collision-safe unique filename (`integration`)
- [ ] persisted job status is `queued` (`integration`)
### `tests/services/test_worker.py`
- [ ] returns `False` when queue empty (`integration`)
- [ ] transitions `queued -> processing -> transcribed` on success (`integration`)
- [ ] stores transcript text on success (`integration`)
- [ ] transitions to `failed` and stores `error_detail` on failure (`integration`)
- [ ] updates existing transcript instead of duplicate create (`integration`)
- [ ] worker loop exits when stop event set (`unit`)
---
## Marker Strategy
- `unit`: pure logic tests (filename handling, loop stop behavior, validation logic)
- `integration`: DB + service orchestration tests (SQLite/session/contracts)
- `external`: opt-in live provider tests only (not part of default Step 4 lane)
No new marker needed; reuse existing marker registration.
---
## Validation Sequence (strict order)
- [ ] `uv run pytest --collect-only -q`
- [ ] `uv run pytest -m unit -q` *(if unit tests touched)*
- [ ] `uv run pytest tests/services/test_upload.py -q`
- [ ] `uv run pytest tests/services/test_worker.py -q`
- [ ] `uv run pytest -q`
---
## Reporting Requirements (after implementation)
Implementation report must include:
1. Files created/updated
2. Fixture and marker decisions
3. MCP references used and why
4. Validation command results
5. Remaining risks/open questions (only blockers)
---
## Guardrails
- Keep Step 4 independent from UI concerns.
- Do not call provider SDK directly from worker.
- Do not silently swallow exceptions.
- Always persist terminal job outcome.
- Keep default suite deterministic and fast.
- Preserve scaffold invariants during fill phase.
---
## Definition of Done (Step 4)
Step 4 is complete when:
- [ ] Upload service writes file and creates `Document` + queued `Job`
- [ ] Worker processes queued jobs end-to-end using Step 3 transcription service
- [ ] Success path persists transcript text and sets `transcribed`
- [ ] Failure path persists error detail and sets `failed`
- [ ] Queue-empty path returns cleanly
- [ ] New tests pass and full suite is green (`uv run pytest -q`)
- [ ] Output report includes MCP reference usage + validation evidence
+70
View File
@@ -0,0 +1,70 @@
Step 5 is implemented and passing ✅
### What I implemented
#### App/bootstrap
- Added `src/transcription/app.py`:
- `create_app()` factory
- FastAPI lifespan startup/shutdown:
- `setup_logging()`
- `create_all()`
- ensure `upload_dir` and `prompt_dir`
- start/stop worker thread (`run_worker_loop`)
- includes health router
- registers NiceGUI pages
#### API
- Added `src/transcription/api/__init__.py`
- Added `src/transcription/api/health.py`:
- `GET /healthz` returns `{"status": "ok"}`
#### UI
- Added `src/transcription/ui/upload_page.py`:
- upload page route
- accepted extensions helper
- upload submit helper to Step 4 service (`create_upload_job`)
- success/error state messaging
- Added `src/transcription/ui/jobs_page.py`:
- jobs list page
- job detail page
- `fetch_jobs()` and `fetch_job_detail()` helpers
- Updated `src/transcription/ui/__init__.py`:
- explicit page registration
- NiceGUI mounted via `ui.run_with(app, mount_path="/ui", ...)`
#### Dependencies
- Added runtime dependencies:
- `fastapi`
- `nicegui`
---
### Tests created and filled (scaffold → fill)
- `tests/test_app.py`
- `tests/api/test_health.py`
- `tests/ui/test_pages_registration.py`
- `tests/ui/test_upload_page.py`
- `tests/ui/test_jobs_page.py`
All scaffolded method names/docstrings were preserved and filled with executable assertions.
---
### Validation results (strict sequence)
- `uv run pytest --collect-only -q`
- `uv run pytest -m unit -q`
- `uv run pytest tests/api/test_health.py -q`
- `uv run pytest tests/ui/test_pages_registration.py -q`
- `uv run pytest tests/test_app.py -q`
- `uv run pytest tests/ui/test_upload_page.py -q`
- `uv run pytest tests/ui/test_jobs_page.py -q`
- `uv run pytest -q`
Full suite is green.
---
### Note
Youll see one warning from FastAPI/Starlette test client about `httpx` deprecation; it does not affect correctness and all tests pass.
+310
View File
@@ -0,0 +1,310 @@
## Step 5: `app.py` + UI Pages (NiceGUI + FastAPI composition)
## Objective
Implement the MVP user-facing application layer so users can:
1. Upload a document from the UI
2. Trigger Step 4 upload/job creation flow
3. See live job lifecycle status (`queued`, `processing`, `transcribed`, `failed`)
4. Open a job detail view to read transcript text or failure details
This step composes Steps 14 into a usable UI.
---
## Architecture Summary (NiceGUI-aligned)
Step 5 uses a **FastAPI app factory + lifespan orchestration** and mounts/registers NiceGUI pages via explicit page modules.
Reference baseline: `resource://skills/nicegui/document`
### Core architecture decisions
- **App factory:** `create_app()`
- **Lifespan-managed resources:** worker start/stop managed in startup/shutdown
- **Modular pages:** upload and jobs pages in separate modules (no monolithic UI file)
- **Health endpoint:** FastAPI-side `/healthz`
- **UI composition:** route pages stay modular and reusable shared shell/components live under `ui/components` as needed
- **Styling architecture:** shared CSS loaded once at startup; avoid ad-hoc per-page styling drift
- **Dependency direction (one-way):**
- `app` -> `config/logging/db/worker/ui/api`
- `ui/pages` -> `ui/components` + `services`
- `services` -> `db/models/providers`
- no reverse imports from services into UI/API
### DB and AI stance (explicit)
- **DB:** already enabled (SQLModel + SQLite), session lifecycle remains request/service-scoped as built in prior steps.
- **AI workflow:** already in place via Step 3 transcription service + Step 4 worker; UI does not call provider SDK directly.
- **Mounted docs:** not in Step 5 scope; docs mounting remains disabled for MVP.
### Async and responsiveness stance
- Prefer `async def` for page handlers and service boundaries when I/O is involved.
- Keep UI handlers non-blocking (no blocking sleeps or synchronous long I/O calls).
- For long-running user actions, always provide explicit loading/progress/error states.
- Keep cancellation/timeout behavior explicit for refresh/poll operations where applicable.
---
## Scope
### In scope
- `src/transcription/app.py`
- `src/transcription/ui/upload_page.py`
- `src/transcription/ui/jobs_page.py`
- `src/transcription/ui/__init__.py`
- `src/transcription/api/health.py` (or equivalent FastAPI health route module)
- UI/app tests with MCP scaffold->fill flow
### Out of scope
- Auth
- advanced filtering/search UX
- batch upload UX beyond MVP
- deployment/container hardening
---
## Planned Deliverables
### Source files
- `src/transcription/app.py` (app factory + lifespan wiring)
- `src/transcription/api/health.py` (GET `/healthz`)
- `src/transcription/ui/upload_page.py` (upload flow)
- `src/transcription/ui/jobs_page.py` (status list + detail)
- `src/transcription/ui/__init__.py` (explicit `register_pages(...)` export)
- `src/transcription/ui/components/*` (shared shell/navigation/status components if introduced)
- `src/transcription/ui/static/*.css` (optional shared CSS loaded once at startup)
### Test files
- `tests/test_app.py`
- `tests/api/test_health.py`
- `tests/ui/test_pages_registration.py`
- `tests/ui/test_upload_page.py`
- `tests/ui/test_jobs_page.py`
---
## Implementation Plan + Checklist
Plan baseline and guardrails source: `resource://skills/nicegui/document`
## Phase A — App factory and lifespan orchestration
- [ ] Create `create_app()` in `src/transcription/app.py`
- [ ] Add FastAPI lifespan startup/shutdown handlers
- [ ] Startup responsibilities:
- [ ] `setup_logging()`
- [ ] `create_all()`
- [ ] ensure directories exist (`upload_dir`, `prompt_dir`)
- [ ] create worker stop event
- [ ] start worker background thread/task
- [ ] Shutdown responsibilities:
- [ ] signal stop event
- [ ] join/cleanup worker thread/task cleanly
- [ ] Register API router(s), including health route
- [ ] Register NiceGUI pages via explicit page registration function
- [ ] Load shared CSS once at startup (if present)
## Phase B — FastAPI health endpoint
- [ ] Create `src/transcription/api/health.py`
- [ ] Add `GET /healthz` returning simple healthy payload
- [ ] Wire route into app factory
## Phase C — Upload page (`ui/upload_page.py`)
- [ ] Add upload route/page registration function
- [ ] Render file input accepting supported extensions
- [ ] On submit:
- [ ] show loading/progress state
- [ ] call `create_upload_job(filename, file_bytes, ...)`
- [ ] show success state with job reference/link
- [ ] On error:
- [ ] show user-safe error message
- [ ] restore ready UI state
- [ ] Ensure non-blocking I/O in UI event handlers; offload CPU-heavy work to worker path
- [ ] Make timeout/cancellation behavior explicit for any long-running action
## Phase D — Jobs page (`ui/jobs_page.py`)
- [ ] Add jobs list route/page registration function
- [ ] Display jobs with status + timestamps
- [ ] Add job detail route/view
- [ ] Show transcript on success, error detail on failure
- [ ] Include explicit refresh action and loading state
- [ ] Ensure error states are surfaced to user and logged
- [ ] Keep refresh path async and bounded to avoid UI freeze
## Phase E — UI registration module
- [ ] Update `src/transcription/ui/__init__.py`
- [ ] Export `register_pages(...)`
- [ ] Ensure each page module exports `register_page(...)`
- [ ] Keep page registration explicit and modular
## Phase F — Shared components and style consistency
- [ ] Add `ui/components` module only for reusable shell elements (header/nav/status chips), not page-local logic
- [ ] Keep structural layout in Python; keep visual polish in shared CSS
- [ ] Avoid one-off styling duplication across upload/jobs pages
---
## MCP Testing Workflow (Required)
Use these resources directly:
- `resource://catalog/prompts/pytest-scaffold`
- `resource://prompts/pytest-scaffold/document`
- `resource://catalog/prompts/pytest-fill-scaffold`
- `resource://prompts/pytest-fill-scaffold/document`
## E1 — Scaffold tests first (structure only)
Target modules:
- `src/transcription/app.py`
- `src/transcription/api/health.py`
- `src/transcription/ui/upload_page.py`
- `src/transcription/ui/jobs_page.py`
Scaffold test files:
- `tests/test_app.py`
- `tests/api/test_health.py`
- `tests/ui/test_pages_registration.py`
- `tests/ui/test_upload_page.py`
- `tests/ui/test_jobs_page.py`
Scaffold constraints:
- [ ] class/method skeletons only
- [ ] one-line docstrings
- [ ] concise behavior-focused names
- [ ] no implementation assertions yet
Validation:
- [ ] `uv run pytest --collect-only -q`
## E2 — Fill scaffold tests
Fill constraints from MCP guidance:
- [ ] preserve scaffold class/method names and docstrings (locked baseline)
- [ ] one behavior target per method
- [ ] deterministic tests preferred
- [ ] minimal mocking; only nondeterministic boundaries
Stack:
- [ ] `fastapi` (or `mixed` if needed for UI+DB fixture combination)
Suggested coverage:
### `tests/api/test_health.py`
- [ ] `/healthz` returns success status and expected payload shape
### `tests/ui/test_pages_registration.py`
- [ ] page registration wiring succeeds
- [ ] expected routes are present
### `tests/test_app.py`
- [ ] startup path initializes runtime dependencies
- [ ] worker start is invoked on startup
- [ ] worker shutdown signal/cleanup is invoked on shutdown
### `tests/ui/test_upload_page.py`
- [ ] upload action calls upload service
- [ ] success feedback displayed
- [ ] error feedback displayed for `UploadError`
- [ ] loading/progress state behavior covered
- [ ] timeout/cancellation behavior covered (if implemented)
### `tests/ui/test_jobs_page.py`
- [ ] list renders job statuses
- [ ] detail shows transcript text for successful job
- [ ] detail shows error detail for failed job
- [ ] refresh/loading state behavior covered
Marker strategy:
- [ ] `unit` for pure helpers/state formatting
- [ ] `integration` for app/page/service+DB contracts
- [ ] `external` not required for default Step 5 lane
Async behavior assertions:
- [ ] long-running actions keep button/inputs in expected disabled state
- [ ] completion/failure returns controls to ready state
---
## Validation Sequence (strict)
- [ ] `uv run pytest --collect-only -q`
- [ ] `uv run pytest -m unit -q` *(if unit tests touched)*
- [ ] `uv run pytest tests/api/test_health.py -q`
- [ ] `uv run pytest tests/ui/test_pages_registration.py -q`
- [ ] `uv run pytest tests/test_app.py -q`
- [ ] `uv run pytest tests/ui/test_upload_page.py -q`
- [ ] `uv run pytest tests/ui/test_jobs_page.py -q`
- [ ] `uv run pytest -q`
---
## Guardrails (NiceGUI + MVP)
- [ ] Do not collapse pages into one file.
- [ ] Do not use implicit global side effects for runtime wiring.
- [ ] Keep UI responsive with explicit loading/progress/error states.
- [ ] Do not block UI handlers with synchronous long I/O.
- [ ] Do not place provider SDK calls in UI handlers.
- [ ] Keep dependency direction one-way and maintainable.
- [ ] Keep shared UI in `ui/components`; keep service logic out of page modules.
---
## Definition of Done
- [ ] App factory + lifespan are in place
- [ ] Health endpoint exists and is tested
- [ ] Upload page creates queued jobs through service boundary
- [ ] Jobs list/detail pages render status/transcript/failure data
- [ ] Worker lifecycle is started/stopped by app lifespan
- [ ] Async UI states (loading/success/error) are deterministic and tested
- [ ] Scaffold->fill testing flow completed and validated
- [ ] Full suite passes: `uv run pytest -q`
## Completion Checks (NiceGUI skill aligned)
- [ ] Uses app factory and FastAPI lifespan
- [ ] Pages are modularized (not single-file UI)
- [ ] Health endpoint exists on FastAPI side
- [ ] Dependency direction is clean and one-way
- [ ] Async-first guidance is applied where I/O exists, with explicit non-blocking UX states
- [ ] DB/AI/docs decisions are explicit and reflected in structure
- [ ] Plan references baseline URI: `resource://skills/nicegui/document`
---
## PR Checklist (Integrated)
### Implementation
- [ ] `app.py` app factory + lifespan implemented
- [ ] FastAPI health route (`/healthz`) implemented
- [ ] `ui/upload_page.py` implemented
- [ ] `ui/jobs_page.py` implemented
- [ ] `ui/__init__.py` explicit page registration implemented
- [ ] Worker startup/shutdown managed by lifespan
### Testing (MCP-compliant)
- [ ] Scaffold phase completed first for all Step 5 tests
- [ ] `--collect-only` passed on scaffolds
- [ ] Fill phase completed without renaming/re-nesting scaffolded tests
- [ ] Marker decisions documented (`unit` vs `integration`)
- [ ] Targeted tests passed
- [ ] Full suite passed
### Evidence
- [ ] Validation command outputs captured
- [ ] Files created/updated listed
- [ ] MCP prompt resources referenced in implementation notes
- [ ] Any residual risks/questions documented
---
+50
View File
@@ -0,0 +1,50 @@
Step 6 implemented ✅
### Files added
- `tests/integration/test_pipeline_flow.py`
- `tests/test_traceability.py`
### What was implemented
#### 1) End-to-end pipeline integration coverage
`tests/integration/test_pipeline_flow.py` now verifies:
- **Success path**
- upload creates queued job
- worker processes job
- job becomes `transcribed`
- transcript text is persisted
- error detail remains `None`
- **Failure path**
- upload creates queued job
- worker processing failure is handled
- job becomes `failed`
- transcript has `text=None`
- `error_detail` contains provider failure reason
#### 2) MVP requirement traceability check
`tests/test_traceability.py` adds a concrete map:
- REQ-0, REQ-1, REQ-2, REQ-3, REQ-4, REQ-5, REQ-6, REQ-8, REQ-12
- each REQ maps to one or more existing test files
- test asserts each mapped path exists (guards against drift)
---
### Validation run results
Executed and passed:
1. `uv run pytest --collect-only -q`
2. `uv run pytest -m unit -q`
3. `uv run pytest -m integration -q`
4. `uv run pytest -m "not external" -q`
5. `uv run pytest tests/integration/test_pipeline_flow.py -q`
6. `uv run pytest tests/ui/test_upload_page.py -q`
7. `uv run pytest tests/ui/test_jobs_page.py -q`
8. `uv run pytest -q`
All green.
(Only existing non-blocking FastAPI TestClient deprecation warning remains.)
+229
View File
@@ -0,0 +1,229 @@
## Step 6: Test and Verification Hardening (MVP closeout)
## Objective
Complete MVP verification by building a **requirements-traceable, deterministic test strategy** across unit/integration/external lanes, then enforcing stable validation commands and reporting.
This step finalizes the MVP implementation sequence from `docs/mvp.md` (Step 6 in the build order: tests and automated verification).
---
## MCP Resource Integration (what was applied)
I reviewed all top-level skills/prompts from `john-stream-mcp` and integrated the relevant guidance into this plan:
### Directly applied
- `resource://skills/pytesting/document`
- `resource://catalog/prompts/pytest-scaffold`
- `resource://prompts/pytest-scaffold/document`
- `resource://catalog/prompts/pytest-fill-scaffold`
- `resource://prompts/pytest-fill-scaffold/document`
- `resource://skills/nicegui/document`
- `resource://skills/nicegui-ui-customization/document`
- `resource://skills/fastapi-uv-docker/document`
- `resource://skills/python-logging-dictconfig/document`
- `resource://skills/python-typing/document`
- `resource://skills/ruff-linting-formating/document`
### Reviewed but informational/non-blocking for Step 6
- `copilot-customization`, `mcp-details`, `vscode-configuration`, `zensical-docs`, and authoring/shim prompts.
- These are primarily customization/documentation tooling resources, not core MVP test-lane blockers.
- Step 6 includes optional workflow follow-ups where relevant (e.g., VS Code task conveniences).
---
## Scope
### In scope
- Strengthen and complete test coverage for the shipped MVP slice (Steps 15)
- Add requirement-to-test traceability for REQ-0..REQ-12 (MVP subset emphasized)
- Enforce deterministic default lanes (`unit`, `integration`)
- Keep `external` lane opt-in and isolated
- Validate app/UI/service/worker contracts end-to-end at test level
### Out of scope
- Major architecture rewrites (async SQLAlchemy migration, queue system, etc.)
- Full production deployment rollout
- Post-MVP feature expansion (revision history, search, export)
---
## Planned Deliverables
### Test files (new/updated)
- `tests/test_traceability.py` *(or docs-based traceability matrix if preferred)*
- `tests/integration/test_pipeline_flow.py` *(upload -> queued -> worker -> transcript/failed)*
- `tests/ui/test_upload_page.py` (augment loading/error/ready-state checks as practical)
- `tests/ui/test_jobs_page.py` (augment refresh/error behavior checks as practical)
- Existing tests touched only when needed; preserve naming/hierarchy unless explicitly approved.
### Optional docs output
- `docs/tests.md` or `docs/verification.md` with lane definitions and command matrix
- REQ-to-test mapping table
---
## Design and Policy Decisions (MCP-aligned)
1. **Scaffold-first, fill-second workflow is mandatory**
- First create/adjust skeletons and collect.
- Then fill test bodies.
- Preserve scaffold names/docstrings during fill.
2. **Deterministic-first default lanes**
- `unit` and `integration` run by default.
- `external` remains explicit opt-in.
3. **One behavior target per test**
- Short, behavior-focused names.
- Precise assertions on observable outcomes.
4. **Test double discipline (from pytesting skill)**
- Prefer real-input/real-object paths first.
- If monkeypatch/mocks/fakes are needed for a boundary, keep narrowly scoped.
- Avoid call-only assertions.
5. **NiceGUI responsiveness expectations**
- Verify loading/success/error state transitions where testable.
- Ensure user-facing feedback behavior is covered.
6. **FastAPI/ops baseline checks**
- Keep `/healthz` route validation in default lanes.
- Keep startup/shutdown lifecycle assertions present.
---
## Implementation Plan + Checklist
## Phase A — Coverage and traceability audit
- [ ] Build a REQ-to-test matrix for MVP requirements:
- [ ] REQ-0, REQ-1, REQ-2, REQ-3, REQ-4, REQ-5, REQ-6, REQ-8, REQ-12
- [ ] Identify weak spots:
- [ ] full pipeline integration (service + worker + persistence)
- [ ] UI state transition assertions (loading/error/ready)
- [ ] failure-path persistence verification robustness
- [ ] Record current baseline command results before edits
## Phase B — Scaffold phase (pytest-scaffold resources)
Target modules/areas:
- pipeline integration flow
- UI behavior augmentations
- traceability checks/document validators (if test-backed)
- [ ] Scaffold new/adjusted test files/classes/methods only
- [ ] Keep one-line intent docstrings
- [ ] Keep behavior-focused names
- [ ] Run: `uv run pytest --collect-only -q`
## Phase C — Fill phase (pytest-fill-scaffold resources)
- [ ] Fill scaffolded methods with deterministic setup/assertions
- [ ] Preserve scaffold names/hierarchy/docstrings
- [ ] Add/adjust fixtures at nearest useful scope
- [ ] Keep DB tests in `integration`; pure helper tests in `unit`
### Required coverage additions
#### Pipeline integration
- [ ] Upload service creates document/job and file path persists
- [ ] Worker success path creates transcript and terminal status
- [ ] Worker failure path persists error detail and terminal failed status
- [ ] Queue-empty behavior remains stable (`False` return / no side effects)
#### UI behavior (practical, testable boundaries)
- [ ] Upload helper flow success and UploadError surfacing
- [ ] Jobs data helpers return stable normalized view models
- [ ] Refresh/detail fallback behavior for missing/invalid job IDs
#### Traceability
- [ ] Every in-scope MVP REQ has at least one mapped test/assertion point
- [ ] Document and/or enforce mapping consistency
## Phase D — External lane stability
- [ ] Keep real-image external tests isolated under `@pytest.mark.external`
- [ ] Ensure no external test leaks into default runs
- [ ] Confirm artifact capture behavior remains stable
## Phase E — Quality gates and workflow
- [ ] Confirm logging/lifecycle startup tests still pass after changes
- [ ] (If enabled) add/update lint/type check commands in docs:
- [ ] Ruff lane (if configured)
- [ ] typing lane (if configured)
- [ ] Optionally add VS Code task aliases for test lanes (non-blocking)
---
## Marker and Fixture Strategy
- `unit`: pure logic, helper behavior, formatting/normalization
- `integration`: DB + service + app lifecycle contracts
- `external`: live provider/real image checks only
Fixture policy:
- Prefer reusable fixtures in `tests/conftest.py` only when broadly shared
- Use subtree/local fixtures for domain-specific setup
- Keep setup explicit and readable
---
## Validation Sequence (strict)
- [ ] `uv run pytest --collect-only -q`
- [ ] `uv run pytest -m unit -q`
- [ ] `uv run pytest -m integration -q`
- [ ] `uv run pytest -m "not external" -q`
- [ ] `uv run pytest tests/integration/test_pipeline_flow.py -q` *(if added)*
- [ ] `uv run pytest tests/ui/test_upload_page.py -q`
- [ ] `uv run pytest tests/ui/test_jobs_page.py -q`
- [ ] `uv run pytest -q`
Optional external verification:
- [ ] `uv run pytest -m external -q`
---
## Guardrails
- Do not rename/re-nest scaffolded tests during fill unless explicitly requested.
- Do not broaden external dependencies in default lane.
- Do not add flaky timing-based assertions; keep deterministic boundaries.
- Keep business logic out of UI tests; test through service/helper boundaries.
- Preserve one-way dependency direction in test setup patterns.
---
## Definition of Done (Step 6)
- [ ] MVP requirement coverage is explicitly traceable
- [ ] Deterministic lanes (`unit` + `integration`) are stable and green
- [ ] External lane remains opt-in and green when enabled
- [ ] Pipeline success/failure lifecycle paths are verified end-to-end
- [ ] UI helper/state behavior has explicit success/error assertions
- [ ] Full suite passes with `uv run pytest -q`
- [ ] Verification evidence is captured in implementation report
---
## PR Checklist (Step 6)
### Implementation
- [ ] Added/updated test files per scoped gaps
- [ ] Added REQ traceability mapping
- [ ] Kept default lanes deterministic
- [ ] Preserved scaffold invariants during fill
### Testing (MCP-compliant)
- [ ] Used scaffold prompt flow first
- [ ] Used fill prompt flow second
- [ ] Preserved naming/docstrings/hierarchy
- [ ] Marker usage documented (`unit`, `integration`, `external`)
### Evidence
- [ ] Collected command outputs in strict order
- [ ] Listed files changed
- [ ] Listed MCP resources used and why
- [ ] Noted residual risks/open questions (if any)
+134
View File
@@ -0,0 +1,134 @@
## Step 7 Results: Error Handling Standardization and Operational Visibility
## Summary
Step 7 was implemented across the MVP runtime boundaries with a shared error taxonomy, actionable UI error surfacing, worker failure normalization, and API error envelope handling.
All required validation gates in `docs/step7.md` were executed and passed.
---
## Scope Delivered
### Implemented
- Shared application error contract and taxonomy
- Service-layer error normalization (upload + transcription)
- UI error presentation helpers with suggested actions and error references
- Worker failure persistence format with category/suggestion/error_id markers
- API exception handlers for structured error responses
- Targeted tests for new error contract behavior
### Not implemented in this step
- External lane execution (`-m external`) was not required for Step 7 completion and was not run in this pass.
---
## Files Added
- `src/transcription/errors.py`
- `src/transcription/api/errors.py`
- `src/transcription/ui/error_presenter.py`
- `tests/test_errors.py`
- `tests/api/test_error_responses.py`
- `docs/step7.md`
## Files Updated
- `src/transcription/app.py`
- `src/transcription/services/upload.py`
- `src/transcription/services/transcription.py`
- `src/transcription/ui/upload_page.py`
- `src/transcription/ui/jobs_page.py`
- `src/transcription/worker.py`
- `tests/services/test_upload.py`
- `tests/services/test_transcription.py`
- `tests/services/test_worker.py`
- `tests/integration/test_pipeline_flow.py`
- `uv.lock`
---
## Implementation Notes by Phase
### Phase A/B (Foundation)
- Added `ErrorCategory` enum and `AppError` base type in `src/transcription/errors.py`.
- Added helper utilities:
- `new_error_id()`
- `build_error_envelope(...)`
- `classify_unexpected_error(...)`
- `format_error_detail(...)`
### Phase C (Service/Provider normalization)
- `UploadError` now extends `AppError` and includes category/suggestion/retriable metadata.
- `PromptLoadError` and `TranscriptionError` now extend `AppError`.
- Provider failures are mapped with deterministic category semantics (auth/payload/provider-failure cases).
### Phase D (UI visibility)
- Added `src/transcription/ui/error_presenter.py`.
- Upload and jobs pages now use centralized UI error rendering and summary helpers.
- UI error paths now include more visible/actionable guidance and reference IDs.
### Phase E (Worker failure handling)
- Worker now normalizes exception handling into structured persisted `error_detail` strings with:
- category marker
- suggestion marker
- error_id marker
- Logging now includes category/error_id context in failure paths.
### Phase F (API envelope)
- Added `src/transcription/api/errors.py` and registered handlers in app factory.
- AppError and unexpected exceptions now serialize to stable API envelopes with mapped status codes.
---
## Validation Commands and Outcomes
All commands were executed with `uv run python -m pytest ...` and completed successfully.
1. `uv run python -m pytest tests/test_errors.py -q`
2. `uv run python -m pytest tests/services/test_upload.py -q`
3. `uv run python -m pytest tests/services/test_transcription.py -q`
4. `uv run python -m pytest tests/providers/test_openrouter.py -q`
5. `uv run python -m pytest tests/services/test_worker.py -q`
6. `uv run python -m pytest tests/integration/test_pipeline_flow.py -q`
7. `uv run python -m pytest tests/api/test_error_responses.py -q`
8. `uv run python -m pytest tests/ui/test_upload_page.py -q`
9. `uv run python -m pytest tests/ui/test_jobs_page.py -q`
10. `uv run python -m pytest -m "not external" -q`
11. `uv run python -m pytest --collect-only -q`
12. `uv run python -m pytest -m unit -q`
13. `uv run python -m pytest -m integration -q`
14. `uv run python -m pytest tests/integration/test_pipeline_flow.py -q`
15. `uv run python -m pytest tests/ui/test_upload_page.py -q`
16. `uv run python -m pytest tests/ui/test_jobs_page.py -q`
17. `uv run python -m pytest -q`
Observed warning (non-blocking): Starlette/FastAPI TestClient deprecation warning related to `httpx` package naming.
---
## Policy Alignment Check (`docs/error_handling.md`)
Aligned items:
- Stable taxonomy categories are implemented.
- Unexpected errors are normalized.
- User-facing UI paths include actionable guidance and references.
- Worker persistence includes trace-friendly failure detail.
- API error responses are structured and category-aware.
Follow-up candidates:
- Add richer UI tests that validate rendered suggested-action content end-to-end (current tests focus helper/service contracts).
- Consider typed storage fields for error metadata instead of packed `error_detail` strings in a future schema revision.
---
## Step 7 Definition of Done Status
- [x] Shared error taxonomy implemented across MVP layers
- [x] GUI error paths upgraded for visibility/actionability
- [x] Worker failure persistence and log context standardized
- [x] API error envelope handling added and tested
- [x] Phase-level and full-suite validation gates passed
- [x] Results documented in this report
Step 7 is complete.
+267
View File
@@ -0,0 +1,267 @@
## Step 7: Error Handling Standardization and Operational Visibility
## Objective
Apply the canonical error policy from `docs/error_handling.md` to the MVP implementation so failures are:
- consistently classified
- visibly surfaced in the GUI
- paired with suggested corrective actions
- traceable through logs via error reference IDs
- validated through deterministic tests after each phase
This step extends MVP hardening by converting current ad hoc exception behavior into a stable cross-layer contract.
---
## Scope
### In scope
- Introduce a shared application error contract and taxonomy implementation
- Normalize service/provider exceptions into taxonomy categories
- Improve GUI error visibility and suggested-action UX
- Standardize worker failure persistence and logging context
- Add API error-envelope policy hooks for current/future endpoints
- Add targeted tests and phase-level/full-suite validation gates
### Out of scope
- Major architecture rewrites (distributed queue, multi-service decomposition)
- Post-MVP feature expansion unrelated to error handling
- Full observability platform rollout (tracing backends, APM)
---
## Policy Source of Truth
- Canonical policy document: `docs/error_handling.md`
- If implementation and policy diverge, policy is authoritative and code/tests must be updated.
---
## Planned Deliverables
### Runtime code
- `src/transcription/errors.py` *(new shared contract module)*
- `src/transcription/ui/error_presenter.py` *(new UI error rendering helper)*
- Updates to:
- `src/transcription/services/upload.py`
- `src/transcription/services/transcription.py`
- `src/transcription/providers/openrouter.py`
- `src/transcription/worker.py`
- `src/transcription/ui/upload_page.py`
- `src/transcription/ui/jobs_page.py`
- `src/transcription/api/*` *(as needed for envelope/handlers)*
### Tests
- `tests/test_errors.py` *(new shared error contract tests)*
- updates/additions in:
- `tests/services/test_upload.py`
- `tests/services/test_transcription.py` *(add if missing)*
- `tests/providers/test_openrouter.py`
- `tests/services/test_worker.py`
- `tests/ui/test_upload_page.py`
- `tests/ui/test_jobs_page.py`
- `tests/api/test_error_responses.py` *(new, if API handlers added)*
### Documentation
- Update `docs/error_handling.md` only if implementation reveals policy gaps
- Capture validation evidence in a Step 7 results artifact (`docs/step7-results.md`)
---
## Design and Policy Decisions
1. **Stable taxonomy contract**
- Use policy categories as stable identifiers (`validation_error`, `user_input_error`, etc.).
2. **Actionable UX is mandatory**
- User-visible errors must include a suggested course of action.
3. **Traceability by default**
- Non-trivial errors include an `error_id` in both logs and user-facing output.
4. **Safe surface / rich logs**
- UI/API show safe summaries; logs retain diagnostic detail and traceback.
5. **Deterministic verification cadence**
- Targeted tests after each change batch, then phase-level regression gates.
---
## Implementation Plan + Checklist
## Phase A — Baseline Validation and Gap Confirmation
- [ ] Run baseline tests before changes
- [ ] Record baseline outputs and any known flaky behavior
- [ ] Confirm current behavior against `docs/error_handling.md` requirements
### Validation gate
- [ ] `uv run pytest -m "not external" -q`
- [ ] `uv run pytest -q`
## Phase B — Shared Error Contract Foundation
- [ ] Add `src/transcription/errors.py` with:
- [ ] stable category enum
- [ ] base `AppError` (category/message/suggestion/error_id/retriable)
- [ ] helpers for error-id generation and fallback classification
- [ ] Keep category names aligned with `docs/error_handling.md`
### Tests
- [ ] Add `tests/test_errors.py`
- [ ] category stability assertions
- [ ] error_id creation behavior
- [ ] fallback classification for unexpected exceptions
### Validation gate
- [ ] `uv run pytest tests/test_errors.py -q`
- [ ] `uv run pytest -m "not external" -q`
## Phase C — Service and Provider Normalization
- [ ] Refactor upload service exceptions to shared taxonomy
- [ ] Refactor transcription service exceptions to shared taxonomy
- [ ] Normalize provider adapter failures into deterministic categories
- [ ] Preserve causal chaining (`raise ... from exc`)
### Tests
- [ ] Extend `tests/services/test_upload.py`:
- [ ] empty payload category/suggestion
- [ ] unsupported extension category/suggestion
- [ ] persistence failure category mapping
- [ ] Add/extend `tests/services/test_transcription.py`:
- [ ] missing/empty prompt behavior
- [ ] unsupported file type behavior
- [ ] provider failure mapping behavior
- [ ] Extend `tests/providers/test_openrouter.py`:
- [ ] auth error mapping
- [ ] malformed response mapping
### Validation gate
- [ ] `uv run pytest tests/services/test_upload.py -q`
- [ ] `uv run pytest tests/services/test_transcription.py -q`
- [ ] `uv run pytest tests/providers/test_openrouter.py -q`
- [ ] `uv run pytest -m "not external" -q`
## Phase D — GUI Visibility and Suggested Actions
- [ ] Add `src/transcription/ui/error_presenter.py`
- [ ] Update upload/jobs pages to use centralized error presentation
- [ ] Ensure GUI surfaces:
- [ ] user-safe message
- [ ] suggested action
- [ ] error reference ID
- [ ] optional technical details panel
- [ ] Replace raw `str(exc)` UX where policy requires safer messaging
### Tests
- [ ] Extend `tests/ui/test_upload_page.py` for actionable error UX paths
- [ ] Extend `tests/ui/test_jobs_page.py` for refresh/detail error guidance
- [ ] Add `tests/ui/test_error_presenter.py` *(optional but recommended)*
### Validation gate
- [ ] `uv run pytest tests/ui/test_upload_page.py -q`
- [ ] `uv run pytest tests/ui/test_jobs_page.py -q`
- [ ] `uv run pytest -m "not external" -q`
## Phase E — Worker Failure Persistence and Logging Context
- [ ] Update worker failure handling to classify errors before persistence
- [ ] Ensure failed jobs persist actionable, structured error detail
- [ ] Add log context fields where available (`error_id`, `category`, `operation`, `job_id`)
- [ ] Ensure retry semantics are explicit and bounded (or clearly documented as deferred)
### Tests
- [ ] Extend `tests/services/test_worker.py`:
- [ ] missing document failure contract
- [ ] provider/transcription failure contract
- [ ] persisted error detail includes category/suggestion/error_id markers
- [ ] Validate integration failure flow in `tests/integration/test_pipeline_flow.py`
### Validation gate
- [ ] `uv run pytest tests/services/test_worker.py -q`
- [ ] `uv run pytest tests/integration/test_pipeline_flow.py -q`
- [ ] `uv run pytest -m "not external" -q`
## Phase F — API Error Envelope Alignment (Current + Future Routes)
- [ ] Add shared API error serialization utilities/handlers (as needed)
- [ ] Ensure API responses can include:
- [ ] `error_id`
- [ ] `category`
- [ ] `message`
- [ ] `suggestion`
- [ ] `timestamp`
- [ ] Map categories to HTTP status guidance from `docs/error_handling.md`
### Tests
- [ ] Add `tests/api/test_error_responses.py` *(if handlers added)*
- [ ] Keep `tests/api/test_health.py` passing
### Validation gate
- [ ] `uv run pytest tests/api/test_error_responses.py -q` *(if added)*
- [ ] `uv run pytest tests/api/test_health.py -q`
- [ ] `uv run pytest -m "not external" -q`
## Phase G — Final Regression and Documentation Closure
- [ ] Reconcile implementation details with `docs/error_handling.md`
- [ ] Update policy doc only where required by confirmed implementation learning
- [ ] Capture execution evidence in `docs/step7-results.md`
### Final validation sequence (strict)
- [ ] `uv run pytest --collect-only -q`
- [ ] `uv run pytest -m unit -q`
- [ ] `uv run pytest -m integration -q`
- [ ] `uv run pytest -m "not external" -q`
- [ ] `uv run pytest tests/integration/test_pipeline_flow.py -q`
- [ ] `uv run pytest tests/ui/test_upload_page.py -q`
- [ ] `uv run pytest tests/ui/test_jobs_page.py -q`
- [ ] `uv run pytest -q`
Optional:
- [ ] `uv run pytest -m external -q`
---
## Guardrails
- Do not weaken user-facing clarity to expose raw internals.
- Do not introduce silent exception swallowing.
- Do not break category-name stability without policy update.
- Do not merge phase changes without passing that phase validation gate.
- Keep targeted tests fast and deterministic; isolate external-provider tests under `external`.
---
## Definition of Done (Step 7)
- [ ] Shared error taxonomy is implemented and used across MVP layers
- [ ] GUI error experiences are visible, actionable, and traceable
- [ ] Worker persists and logs failure context consistently
- [ ] API error contract path is aligned for current/future endpoints
- [ ] Phase-by-phase test gates pass
- [ ] Full suite remains green (`uv run pytest -q`)
- [ ] Step 7 results are documented with evidence
---
## PR Checklist (Step 7)
### Implementation
- [ ] Added shared error contract module
- [ ] Updated service/provider/worker/UI error handling paths
- [ ] Added actionable GUI guidance for user-visible failures
- [ ] Added error reference IDs for traceability
### Testing
- [ ] Added/updated tests per phase scope
- [ ] Ran targeted phase tests after each change batch
- [ ] Ran `not external` regression at each phase boundary
- [ ] Ran full suite before closeout
### Documentation and Evidence
- [ ] `docs/error_handling.md` reviewed for alignment
- [ ] `docs/step7-results.md` includes executed command outputs
- [ ] Residual risks and deferred items explicitly recorded
+209
View File
@@ -0,0 +1,209 @@
## MVP Definition: Historical Document Transcription System
### 1. MVP Objective
Deliver the thinnest possible end-to-end vertical slice — a user uploads an image of a document, the system transcribes it via the OpenRouter Python SDK, and the user reads the resulting transcript — with just enough persistence and structure to validate the core value proposition: *can AI-driven transcription, guided by curated prompts, produce useful verbatim transcripts of historical family documents?*
The MVP deliberately defers full-text search, export, revision history, MongoDB, and timeline assembly. These are additive features that don't need validation before the core transcription loop is proven.
---
### 2. Core User Story
*As a family historian, I can upload a photo of a historical document, wait for it to be transcribed, and read the verbatim transcript — so I can evaluate whether this system will work for my thousands of documents.*
---
### 3. In-Scope Requirements (from ```requirements.md```)
| Requirement | ID | MVP Rationale |
| --- | --- | --- |
| End-to-end transcription with lifecycle state | REQ-0 | This is the MVP. |
| Upload one or more images from the web UI | REQ-1 | Core entry point. MVP supports single-image upload (multi-image is a stretch goal). |
| Asynchronous processing → transcription or failure | REQ-2 | Validates the AI transcription pipeline. |
| Persist and expose job states (queued → processing → transcribed/failed) | REQ-3 | Minimum feedback loop for the user. |
| Persist transcription output and failure details | REQ-4 | User must be able to read the result. |
| UI views for status and transcript reading | REQ-5 | The user needs to see what happened. |
| Background processing to keep UI responsive | REQ-6 | Essential for usability during long AI calls. |
| Centralized config and logging at startup | REQ-8 | Small effort, high payoff for debugging. |
| Store transcription prompts as Markdown files | REQ-12 | Core to the Prompt Curation Policy in intent.md. Start with a single prompt file. |
### Deferred to Post-MVP
| Requirement | ID | Why Deferred |
| --- | --- | --- |
| Lifespan-owned runtime resources (engine, session factory, etc.) | REQ-7 | Important for production robustness, but a simple global or module-level setup is adequate for MVP validation. |
| Docker Compose (app + PostgreSQL + optional MongoDB) | REQ-9 | MVP runs locally with SQLite to eliminate container overhead during rapid iteration. PostgreSQL migration is Stage 1 hardening. |
| Explicit, opt-in schema bootstrap | REQ-10 | MVP uses auto-create-tables at startup (SQLModel create_all). Production schema discipline comes after the model stabilizes. |
| Service-backed persistence for core data | REQ-11 | MVP uses a thin repository layer over SQLite. Full service abstraction follows once the domain model is proven. |
---
### 4. MVP Feature Set
#### Feature 1: Document Upload (UI)
* A single NiceGUI page with a file-upload widget (accepts .jpg, .png, .tiff, .pdf).
* On upload: save the file to a local uploads/ directory, create a Document record, create a Job record with status queued.
* Minimal metadata capture: original filename, upload timestamp.
#### Feature 2: Asynchronous Transcription Worker
* An in-process background worker (Python asyncio task or BackgroundTasks) that:
1. Picks up queued jobs.
2. Transitions status to processing.
3. Sends the image + the curated Markdown prompt to an AI vision model via OpenRouter.
4. On success: saves the transcript text, transitions to transcribed.
5. On failure: saves the error detail, transitions to failed.
#### Feature 3: Transcription Prompt (Markdown Asset)
* A single Markdown file (prompts/transcribe_document.md) encoding the verbatim transcription rules from intent.md (the Document Issues table, scholarly guidelines, etc.).
* The worker reads this file at invocation time and injects it as the system/user prompt.
#### Feature 4: Job Status & Transcript Viewer (UI)
* A job list page showing all jobs with their current status (queued / processing / transcribed / failed).
* A transcript detail page showing:
* The original uploaded image (rendered inline).
* The transcription text (or the failure reason).
* Timestamp metadata.
#### Feature 5: Minimal Persistence (SQLite + SQLModel)
* Three tables/models:
* Document: id, filename, file_path, uploaded_at.
* Job: id, document_id (FK), status, created_at, updated_at.
* Transcript: id, job_id (FK), text, error_detail, created_at.
* SQLite database file stored locally. Auto-created on first startup.
#### Feature 6: Centralized Configuration
* A single config.py (or Pydantic BaseSettings) loading:
* PROVIDER (fixed to openrouter for MVP)
* OPENROUTER_API_KEY (required)
* PROVIDER_MODEL (default: OpenRouter model slug for vision transcription)
* OPENROUTER_HTTP_REFERER (optional; app attribution)
* OPENROUTER_APP_TITLE (optional; app attribution)
* DATABASE_URL (default: sqlite:///./transcription.db)
* UPLOAD_DIR (default: ./uploads)
* PROMPT_DIR (default: ./prompts)
#### Feature 7: MVP Dependency Baseline (OpenRouter-Centric)
* Runtime dependencies:
* openrouter (official OpenRouter Python SDK)
* pydantic
* pydantic-settings
* sqlmodel
* Explicitly out of MVP runtime dependencies:
* google-genai (deferred until/if Gemini is introduced post-MVP)
---
### 5. MVP Architecture (Simplified)
```Apply
┌─────────────────────────────────────────────┐
│ NiceGUI Web UI │
│ ┌──────────────┐ ┌───────────────────┐ │
│ │ Upload Page │ │ Jobs / Transcript │ │
│ └──────┬───────┘ └───────┬───────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌───────────────────────────┐ │
│ │ Application Service │ │
│ │ (upload, job lifecycle) │ │
│ └─────┬─────────────┬───────┘ │
│ │ │ │
│ ┌─────▼─────┐ ┌─────▼───────────────┐ │
│ │ SQLite DB │ │ Background Worker │ │
│ │ (SQLModel)│ │ → AI Vision Provider│ │
│ └───────────┘ └─────────────────────┘ │
│ │ │
│ ┌─────▼──────┐ │
│ │ prompts/ │ │
│ │ *.md files │ │
│ └────────────┘ │
└─────────────────────────────────────────────┘
```
---
#### 6. Proposed File Structure
```Apply
project-root/
├── docs/ # (existing)
├── prompts/
│ └── transcribe_document.md # curated transcription prompt
├── src/
│ └── transcription/
│ ├── __init__.py
│ ├── app.py # FastAPI + NiceGUI app entrypoint
│ ├── config.py # Pydantic BaseSettings
│ ├── models.py # SQLModel: Document, Job, Transcript
│ ├── db.py # engine, session, create_all
│ ├── providers/
│ │ ├── __init__.py
│ │ ├── base.py # provider interface (transcribe contract)
│ │ ├── openrouter.py # OpenRouter via official Python SDK
│ ├── services/
│ │ ├── __init__.py
│ │ ├── upload.py # save file + create records
│ │ └── transcription.py # call provider, update job
│ ├── worker.py # background job loop
│ └── ui/
│ ├── __init__.py
│ ├── upload_page.py # NiceGUI upload page
│ └── jobs_page.py # NiceGUI job list + detail
├── tests/
│ ├── test_models.py
│ ├── test_upload.py
│ └── test_transcription.py
├── pyproject.toml
└── README.md
```
---
#### 7. MVP Validation Criteria
The MVP is considered validated when:
1. ✅ A user can upload an image of a document through the browser.
2. ✅ The system asynchronously sends the image to the configured AI vision model with the curated prompt.
3. ✅ The transcript (or failure reason) is persisted and visible in the UI.
4. ✅ The transcription follows verbatim scholarly rules defined in intent.md (spot-checked by the user on real family documents).
5. ✅ The transcription prompt is stored as a standalone Markdown file and can be edited without code changes.
6. ✅ Job status transitions are visible: queued → processing → transcribed/failed.
---
### 8. Key Feedback Questions the MVP Should Answer
These are the real unknowns this MVP exists to resolve:
| # | Question | How We Learn |
| --- | --- | --- |
| 1 | Is AI transcription quality good enough for this document corpus? | User reviews 2050 real transcriptions against originals. |
| 2 | Does the verbatim prompt produce scholarly-quality output, or does it need major rework? | Compare output to the Document Issues table rules in intent.md. |
| 3 | What document types are hardest (old cursive, faded ink, pencil, postcards)? | Track which uploads produce failed or low-quality results. |
| 4 | Is single-image upload sufficient, or is batch upload needed early? | User friction during real scanning sessions. |
| 5 | What metadata is missing that the user wishes they could capture at upload time? | User feedback after processing real batches. |
---
#### 9. What Comes After MVP (Immediate Post-MVP)
Once the core transcription loop is validated, the next priorities (aligned to Architecture Stage 1) are:
1. **Multi-image upload** — process a batch from a scanning session.
2. **PostgreSQL migration** — swap SQLite for containerized PostgreSQL (REQ-9, REQ-10).
3. **Revision history** — allow the user to edit/correct transcripts with immutable version tracking.
4. **Full-text search** — search across all accepted transcripts.
5. **Repository/service layer formalization** — proper ports/adapters as the domain model stabilizes.
6. **Docker Compose deployment** — containerize the app for reproducible operation.
---
#### 10. Implementation Approach
Recommended build order for the MVP (each step produces a testable increment):
| Step | Deliverable | Validates |
| --- | --- | --- |
| 1 | config.py + models.py + db.py — data layer with SQLite | Schema and config foundation |
| 2 | prompts/transcribe_document.md — curated prompt from intent.md | Prompt asset pattern |
| 3 | services/transcription.py + providers/ — call AI vision provider with prompt + image | Core AI integration |
| 4 | services/upload.py + worker.py — upload handling + background job loop | End-to-end pipeline (CLI-testable) |
| 5 | ui/upload_page.py + ui/jobs_page.py — NiceGUI pages | User-facing interface |
| 6 | tests/ — unit + integration tests Automated verification |
This MVP is deliberately narrow: **one prompt, one provider (OpenRouter), one user, one image at a time, SQLite, no containers**. Every omission is intentional — the goal is to get real family documents through the transcription pipeline as fast as possible and let the quality of the output guide every subsequent decision.
+84
View File
@@ -0,0 +1,84 @@
## Document Transcription System Requirements
This page captures a SysML v1.6-style requirements baseline for the production system described in [index.md](index.md). The model is represented as concise tables and traceability lists that preserve SysML-style IDs and relationship semantics.
## Scope
- System of interest: the single Python application service (NiceGUI + FastAPI) with PostgreSQL as the relational system of record and optional MongoDB for document-oriented persistence.
- Operational context: local-first execution with Docker Compose and an intentionally lightweight production trajectory.
- Primary concern: end-to-end transcription job lifecycle from upload through completion or failure.
## Requirements Model (Concise Text Form)
### Requirements
| ID | Category | Requirement | Risk | Verify Method |
| --- | --- | --- | --- | --- |
| REQ-0 | System | Provide end-to-end document transcription with persistent, inspectable lifecycle state. | medium | demonstration |
| REQ-1 | Functional | Allow users to upload one or more document images from the web UI. | low | test |
| REQ-2 | Functional | Run each upload through asynchronous processing that returns a transcription or explicit failure. | high | test |
| REQ-3 | Functional | Persist and expose job states: upload, queued, processing, transcribed, failed, completed. | high | inspection |
| REQ-4 | Functional | Persist transcription output, processing history, and failure details. | medium | test |
| REQ-5 | Interface | Expose API and UI views for status inspection and completed transcription reading. | medium | demonstration |
| REQ-6 | Performance | Trigger background processing on upload to preserve UI responsiveness. | medium | analysis |
| REQ-7 | Design Constraint | Keep lifespan-owned runtime resources: SQLAlchemy engine, async session factory, worker resources, provider clients. | medium | inspection |
| REQ-8 | Design Constraint | Initialize configuration and logging once at startup through centralized mechanisms. | low | inspection |
| REQ-9 | Design Constraint | Use Docker Compose baseline of app plus PostgreSQL; allow optional MongoDB container when enabled. | medium | demonstration |
| REQ-10 | Design Constraint | Keep schema bootstrap explicit and opt-in; normal startup does not mutate production schema. | high | inspection |
| REQ-11 | Design Constraint | Use service-backed persistence for core document and job data. | medium | inspection |
| REQ-12 | Design Constraint | Store transcription prompts as individual Markdown artifacts for iterative refinement. | medium | inspection |
### Requirement Relationships
- Contains: REQ-0 contains REQ-1 through REQ-12.
- Derives: REQ-2 -> REQ-3, REQ-3 -> REQ-4.
- Traces: REQ-5 -> REQ-3.
- Refines: REQ-6 -> REQ-2.
### Architecture Elements
| Element | Type | Doc Reference |
| --- | --- | --- |
| UI | NiceGUI pages | src/transcription/ui/pages |
| API | FastAPI routes | src/transcription/api/routes.py |
| GRAPH | Async processing workflow | src/transcription/services, src/transcription/ai |
| DBREL | PostgreSQL + SQLModel relational persistence | src/transcription/db |
| DBDOC | MongoDB document persistence | src/transcription/db, src/transcription/services |
| OPS | Docker Compose runtime | docker-compose.yml |
| PROMPTS | Transcription prompt artifact library (Markdown files) | .github/prompts, docs |
| TESTS | Pytest verification suite | tests |
### Satisfaction Mapping
- UI satisfies REQ-1, REQ-5.
- API satisfies REQ-5.
- GRAPH satisfies REQ-2, REQ-6.
- DBREL satisfies REQ-3, REQ-10.
- DBDOC satisfies REQ-4, REQ-11.
- OPS satisfies REQ-9.
- PROMPTS satisfies REQ-12.
### Verification Mapping
- TESTS verifies REQ-1, REQ-2, REQ-3, REQ-4, REQ-5, REQ-10, REQ-11, REQ-12.
## Requirement Notes
- Requirement IDs (`REQ-*`) are stable references for planning, implementation, and test traceability.
- The model uses compact tables and traceability lists for renderer compatibility while preserving SysML-style requirement IDs and relationship semantics.
- Requirement categories (functional, interface, performance, and design constraints) are preserved as explicit REQ entries and relationship labels to keep change impact visible.
- PostgreSQL containerization and optional MongoDB containerization are both treated as extremely lightweight and simple operational choices in this architecture.
## Verification Intent
- Demonstration: validate end-to-end behavior via running system flows and operator-visible outcomes.
- Inspection: verify architecture and startup/runtime policies in code and configuration.
- Analysis: evaluate asynchronous execution behavior and design sufficiency.
- Test: automate behavioral checks through pytest suites and service-level tests.
## Glossary
- Document-oriented persistence: A storage approach that uses flexible document structures for variable data shapes.
- Prompt artifact: A single Markdown file that defines one transcription prompt and is revised independently.
- SysML: Systems Modeling Language used to express structured requirements and traceability.
- System of record: The authoritative persistent store for canonical business data.
@@ -0,0 +1,73 @@
# Ver1 Step 1/2 Carry-Forward Checklist
## Purpose
Track open Step 1 and Step 2 follow-ups through later V1 steps, with lightweight verification evidence and requirement traceability.
This artifact implements the carry-forward approach defined in:
- `docs/ver1/ver1-step1-2_revised.md`
Historical records remain unchanged:
- `docs/ver1/ver1-step1.md`
- `docs/ver1/ver1-step1-results.md`
- `docs/ver1/ver1-step2.md`
- `docs/ver1/ver1-step2-results.md`
---
## Status Legend
- `not started`
- `in progress`
- `done`
- `deferred`
---
## Carry-Forward Mapping Matrix
| ID | Carry-Forward Task | Source | Related REQ | Owning V1 Step(s) | Validation Method | Status | Evidence Link/Note |
| --- | --- | --- | --- | --- | --- | --- | --- |
| CF-A1 | Confirm remaining implicit/global runtime ownership and lift only high-impact resources to lifespan ownership | Step 1 residual follow-up | REQ-7 | Step 3, Step 9 | Inspection + test | in progress | Step 3 added `services/library.py` and `api/routes.py` using existing service/session access patterns; no new module-global runtime resource ownership introduced. Reconfirm in Step 9 release readiness. |
| CF-A2 | Finalize migration + rollback runbook usage and rehearse on representative local data | Step 1 residual follow-up | REQ-10 | Step 4, Step 9 | Demonstration + test | not started | |
| CF-A3 | Maintain lightweight boundary enforcement (review checklist and/or simple import checks) | Step 1 residual follow-up | REQ-7, REQ-11 | Step 3, Step 7 | Inspection | in progress | Step 3 implementation keeps UI/API composition thin and pushes revision/search/export logic to `services/library.py`; continue with Step 7 checks. |
| CF-B1 | Build compact error-path inventory for major failure paths and category mapping | Step 2 governance follow-up | REQ-2, REQ-3, REQ-4, REQ-5 | Step 6, Step 7 | Inspection | not started | Use `docs/ver1/ver1-step2-error-path-inventory.md` |
| CF-B2 | Standardize required logging fields at critical boundary handoffs | Step 2 residual follow-up | REQ-3, REQ-4, REQ-8 | Step 6 | Inspection + test | not started | |
| CF-B3 | Revisit retry backoff strategy only if observed runtime behavior justifies extra complexity | Step 2 residual follow-up | REQ-2, REQ-6 | Step 6, Step 8 | Analysis + test | deferred | Keep fixed backoff unless evidence suggests change |
| CF-C1 | Integrate Step 1/2 completed outcomes and open follow-ups into V1 traceability tracking | Revision-plan workstream | REQ-0..REQ-12 (traceability) | Step 3, Step 10 | Inspection | done | Step 3 artifacts added: `docs/ver1/ver1-step3.md`, `docs/ver1/ver1-step3-results.md`, and this checklist updated with Step 3 evidence and routing. |
| CF-C2 | Keep carry-forward routing aligned with revised V1 plan (architecture via 3/4/9, reliability via 6/7) | Revision-plan workstream | REQ-0..REQ-12 (execution alignment) | Step 3+ | Inspection | in progress | Step 3 execution followed routing: functional features implemented in Step 3; migration/rollback items remain in Step 4/9; logging/error-path standardization remains Step 6/7. |
---
## Execution Notes
### Step 3 (Functional Completion)
- Use CF-A1 and CF-A3 during requirement-slice implementation reviews.
- Record any discovered boundary/runtime ownership gaps in this checklist.
### Step 4 (Data Model and Migration Safety)
- Execute CF-A2 rehearsal and link evidence (commands, runbook notes, outcomes).
### Step 6 (Minimal Observability & Operability)
- Execute CF-B1 and CF-B2 with focused artifacts and log-field verification.
### Step 7 (Test Coverage and Practical Quality Gates)
- Add/verify tests supporting CF-A3 and CF-B1/B2 where meaningful.
### Step 8 (Performance Validation)
- Reassess CF-B3 only if retries/backoff are observed to cause practical issues.
### Step 9 (Release Readiness)
- Reconfirm CF-A1/A2 readiness in release checklist and rollback drill.
### Step 10 (Documentation Completion)
- Ensure final V1 docs reference outcomes from this checklist where relevant.
---
## Acceptance Check for Carry-Forward Completion
- [ ] Historical Step 1/2 documents remain unchanged.
- [ ] Every open Step 1/2 follow-up has an owning V1 step and validation method.
- [ ] Evidence links are recorded for each completed carry-forward item.
- [ ] No carry-forward item introduces unnecessary complexity for personal-scale operation.
+166
View File
@@ -0,0 +1,166 @@
# Ver1 Step 1 & Step 2 Revision Plan (Additive)
## Purpose
Define a **targeted implementation follow-through plan** for Step 1 and Step 2 outcomes so remaining V1 work stays aligned with `docs/ver1/ver1.md`:
- personal-scale operation
- single operator
- private-network assumptions
- low operational overhead
- practical, testable controls
This document is additive and does **not** replace or revise historical Step 1/Step 2 records.
---
## Source Documents Reviewed
- `docs/ver1/ver1.md`
- `docs/ver1/ver1-step1.md`
- `docs/ver1/ver1-step1-results.md`
- `docs/ver1/ver1-step2.md`
- `docs/ver1/ver1-step2-results.md`
- `docs/architecture.md`
- `docs/error_handling.md`
- `docs/requirements.md`
- `docs/index.md`
- `docs/intent.md`
---
## Revision Goals
1. Preserve all completed Step 1/Step 2 technical hardening work.
2. Keep historical Step 1/Step 2 documents unchanged.
3. Convert residual risks/follow-ups into concrete implementation tasks for subsequent V1 steps.
4. Preserve traceability to requirements and implemented evidence.
5. Maintain alignment with personal-scale architecture and operating model.
---
## Scope
### In Scope
- Define carry-forward implementation tasks based on Step 1/2 residual risks and open items.
- Map carry-forward tasks to later V1 steps (especially Steps 3, 4, 6, 7, and 9).
- Define lightweight verification evidence expected for each carry-forward task.
- Update V1 traceability references to include completed Step 1/2 outcomes and deferred follow-ups.
### Out of Scope
- Simplifying tone/structure of existing Step 1/2 documents
- Clarifying or rewriting historical Step 1/2 plan/results content
- Editing `docs/ver1/ver1-step1.md`
- Editing `docs/ver1/ver1-step1-results.md`
- Editing `docs/ver1/ver1-step2.md`
- Editing `docs/ver1/ver1-step2-results.md`
- Re-implementing Step 1/2 code changes
- Rewriting `docs/ver1/ver1.md`
- Deleting historical sections/results
- Altering requirements IDs or architecture principles
---
## Carry-Forward Implementation Plan
## Workstream A — Close Step 1 follow-ups through later V1 steps
### A1) Runtime ownership completion (REQ-7 continuity)
- Confirm whether any remaining runtime resources still use implicit/global ownership.
- Move only high-impact remaining resources to explicit lifespan ownership when needed.
- Keep ownership model simple and documented.
### A2) Schema/migration operations readiness (REQ-10 continuity)
- Finalize practical migration + rollback runbook usage in Step 4 execution.
- Rehearse upgrade and rollback on representative local data.
- Keep production startup free from implicit schema mutation.
### A3) Boundary enforcement (lightweight only)
- Keep architecture boundary checks lightweight (review checklist and/or simple import checks).
- Avoid heavy governance tooling unless clear recurring drift appears.
### Expected Outcome
Step 1 architecture hardening remains intact and is completed pragmatically where open items remain.
---
## Workstream B — Close Step 2 follow-ups through later V1 steps
### B1) Error-path inventory and coverage visibility
- Create a compact error-path inventory artifact (or equivalent matrix section) covering major failure paths.
- Ensure each critical path maps to category, retriable policy, and surfaced behavior.
### B2) Logging field consistency at key boundaries
- Standardize required fields at critical failure handoffs (`error_id`, `category`, `operation`, identifiers when available).
- Prioritize worker/API/service boundaries first.
### B3) Retry policy refinement (only if needed)
- Keep current bounded retry baseline.
- Revisit richer backoff strategy only if observed behavior justifies added complexity.
### Expected Outcome
Step 2 reliability behavior stays stable, diagnosable, and right-sized for personal-scale operation.
---
## Workstream C — Integrate Step 1/2 outputs into ongoing V1 governance
### C1) Traceability integration
- Link completed Step 1/2 outcomes and deferred follow-ups to the V1 traceability matrix.
- Ensure open follow-ups have owning step and validation method.
### C2) Execution alignment with revised V1 plan
- Route architecture follow-ups primarily through Steps 3/4/9.
- Route reliability/diagnostics follow-ups primarily through Steps 6/7.
### Expected Outcome
Step 1/2 work is fully carried forward without revising historical documents.
## Deliverables
1. This document (`docs/ver1/ver1-step1-2_revised.md`) as the carry-forward implementation plan.
2. A compact Step 1/2 carry-forward checklist linked to V1 steps and validation methods.
3. Traceability updates showing where each open Step 1/2 follow-up will be closed.
4. Optional new artifact for error-path inventory (if created during Step 6/7 execution).
---
## Acceptance Criteria
- Historical Step 1/Step 2 documents remain unchanged.
- Open Step 1/2 follow-ups are explicitly mapped to later V1 steps with validation expectations.
- No loss of core technical intent (REQ-7, REQ-10, error taxonomy, retry safety, traceability).
- No conflicts introduced with `docs/architecture.md`, `docs/error_handling.md`, or `docs/ver1/ver1.md`.
- Carry-forward tasks remain right-sized for personal-scale operation.
---
## Implementation Order
1. Keep existing Step 1/Step 2 docs unchanged as historical records.
2. Define carry-forward tasks and owning V1 steps in this document.
3. Create and maintain carry-forward traceability artifacts:
- `docs/ver1/ver1-step1-2-carry-forward-checklist.md`
- `docs/ver1/ver1-step2-error-path-inventory.md`
4. Execute carry-forward tasks during Steps 3+ and capture evidence in step results docs.
5. Perform final consistency pass across `docs/ver1/*` references.
---
## Risks and Mitigations
1. **Risk:** Open Step 1/2 items are forgotten as Step 3+ work proceeds.
**Mitigation:** Track each follow-up in the V1 traceability matrix with owning step and evidence expectation.
2. **Risk:** Carry-forward work expands beyond personal-scale needs.
**Mitigation:** Apply simplicity guardrails from `docs/architecture.md` before accepting additional hardening tasks.
3. **Risk:** Reliability follow-ups become fragmented across multiple steps.
**Mitigation:** Keep one consolidated carry-forward checklist and update it at milestone check-ins.
---
## Notes
This revision effort is scope-alignment and implementation-follow-through focused.
Historical Step 1/Step 2 documents are intentionally preserved as-is.
+86
View File
@@ -0,0 +1,86 @@
# Ver1 Step 1 Results: Architecture Consolidation
## Summary
Step 1 implementation has been completed for the primary architecture-consolidation objectives:
1. Lifespan-owned runtime resource model introduced for DB runtime ownership.
2. Schema bootstrap policy changed from implicit-always to explicit/environment-aware.
3. Worker startup now receives lifespan-owned DB engine dependency.
4. ADR set established for key V1 architectural decisions.
## Implemented Changes
### 1) Runtime ownership
- Updated `src/transcription/db.py`:
- Added `DatabaseRuntime` resource model.
- Added explicit runtime lifecycle methods:
- `initialize_database_runtime(...)`
- `get_database_runtime()`
- `dispose_database_runtime()`
- Updated `src/transcription/app.py`:
- Lifespan initializes DB runtime and stores it on `app.state`.
- Lifespan disposes DB runtime on shutdown.
### 2) Schema bootstrap policy (REQ-10 alignment)
- Updated `src/transcription/config.py`:
- Added `environment` setting (`development`, `test`, `production`).
- Added `bootstrap_schema_on_startup` explicit override setting.
- Updated `src/transcription/db.py`:
- Added `should_bootstrap_schema(settings)` policy function.
- Updated `src/transcription/app.py`:
- Startup now calls `create_all(...)` only when policy allows.
### 3) Worker dependency ownership
- Updated `src/transcription/worker.py`:
- `process_next_queued_job(..., engine=None)` now supports explicit engine injection.
- `run_worker_loop(..., engine=None, ...)` now supports explicit engine injection.
- Updated `src/transcription/app.py`:
- Worker thread is started with lifespan-owned engine.
### 4) ADR governance
Created:
- `docs/adr/README.md`
- `docs/adr/ADR-0001-lifespan-owned-runtime-resources.md`
- `docs/adr/ADR-0002-explicit-schema-bootstrap-policy.md`
- `docs/adr/ADR-0003-persistence-baseline-and-transition-path.md`
- `docs/adr/ADR-0004-in-process-worker-topology.md`
## Test Evidence
Targeted regression checks executed successfully:
- `uv run pytest tests/test_app.py tests/test_db.py tests/services/test_worker.py -q`
- Result: pass
## Residual Risks / Follow-ups
1. Full REQ-7 completion may still require broader runtime ownership coverage for additional resources as V1 expands.
2. Production schema management workflow (migrations/runbook tooling) should be finalized in subsequent V1 steps.
3. Additional boundary enforcement automation (import-lint style checks) can be added in later hardening.
## Step 1 Exit Assessment
- Architecture ownership clarity: **met**
- Schema bootstrap policy hardening: **met**
- Worker lifecycle dependency clarity: **met**
- ADR baseline established: **met**
## Completion Checklist With Evidence
| Criterion | Status | Evidence |
| --- | --- | --- |
| Architecture conformance matrix approved | partial | Consolidation implemented and documented in `docs/ver1/ver1-step1.md` + this results doc; formal matrix artifact can be added as a follow-up appendix. |
| REQ-7 ownership gaps resolved or explicitly deferred | met | Lifespan-owned DB runtime and explicit worker engine wiring implemented in `src/transcription/app.py`, `src/transcription/db.py`, `src/transcription/worker.py`. Residual scope documented under follow-ups. |
| REQ-10 explicit bootstrap policy implemented and verified | met | Policy implemented via `environment` + `bootstrap_schema_on_startup` in `src/transcription/config.py`, `should_bootstrap_schema(...)` in `src/transcription/db.py`, startup gate in `src/transcription/app.py`, tested in `tests/test_db.py`. |
| Dependency direction rules documented and enforced | partial | Layering and runtime ownership documented in `docs/architecture.md`. Lightweight enforcement exists via review and test discipline; automated import-lint remains a follow-up. |
| ADR set created for major Step 1 decisions | met | `docs/adr/README.md` and ADR-0001 through ADR-0004 created. |
| Architecture/index docs updated to match implementation | met | `docs/architecture.md` and `docs/index.md` updated with V1 Step 1 runtime policy and links to V1/ADR artifacts. |
| Regression and full test suites pass | met | Targeted: `uv run pytest tests/test_app.py tests/test_db.py tests/services/test_worker.py -q`; full suite: `uv run pytest -q`. |
| Step 1 results artifact published | met | This document (`docs/ver1/ver1-step1-results.md`) created and updated with summary, evidence, risks, and checklist. |
Step 1 is complete and ready to hand off to Ver1 Step 2.
+309
View File
@@ -0,0 +1,309 @@
# Step 1 Implementation Plan: Architecture Consolidation
## Purpose
Align the implemented MVP codebase with the production architecture and V1 constraints documented in:
- `docs/architecture.md`
- `docs/requirements.md`
- `docs/error_handling.md`
- `docs/index.md`
- `docs/intent.md`
- `docs/ver1/ver1.md` (Step 1)
This step hardens architecture boundaries and ownership without expanding product scope.
---
## MCP Skill and Guide Inputs Incorporated
This plan explicitly incorporates patterns and guardrails from john-stream-mcp resources:
1. `resource://skills/fastapi-uv-docker/document`
- App factory and lifespan ownership
- Health endpoint and cloud-native baseline expectations
- Environment-driven configuration and startup discipline
2. `resource://skills/fastapi-async-sqlalchemy-modernization/document`
- Current-state gap audit first
- Target runtime model before refactor
- Explicit resource lifecycle ownership
- Transaction/session boundary clarity
- Phased migration with rollback points
3. `resource://skills/nicegui/document`
- Clear dependency direction
- UI/page registration as composition, not business logic container
- Async responsiveness and boundary separation
4. `resource://prompts/greenfield-architecture/document`
- Pattern-comparison-first planning
- Explicit tradeoffs and staged implementation
- Output contract with risks, open questions, and next steps
---
## Current-State Gap Summary (Architecture vs Implementation)
Based on docs and current `src/transcription` code:
1. **REQ-7 gap (lifespan-owned resources)**
- DB engine/session factory are module globals in `db.py`, not app lifespan-owned.
- Worker thread lifecycle is owned by lifespan (good), but DB/provider resource ownership is mixed.
2. **REQ-10 gap (explicit opt-in schema bootstrap)**
- `create_all()` is executed unconditionally on startup in `app.py`.
3. **Data store target gap (REQ-9 + architecture baseline)**
- Runtime still defaults to SQLite MVP setup; production architecture targets PostgreSQL baseline with optional MongoDB.
4. **Layering clarity gap (architecture layer model)**
- Boundaries exist but are not yet formally enforced (interface/app/domain/infra dependency rules are implicit, not codified).
5. **Decision record gap**
- No ADR set documenting key V1 architectural decisions and deviations from MVP.
---
## Scope for Step 1
### In scope
1. Produce architecture conformance audit and decision records.
2. Define and implement target runtime ownership model for core resources.
3. Establish explicit schema bootstrap policy (opt-in in production paths).
4. Consolidate module boundaries and dependency direction rules.
5. Update architecture docs to reflect implemented reality and V1 trajectory.
### Out of scope
- Full async SQLAlchemy rewrite (plan and seams only if deferred)
- MongoDB feature implementation
- New user-facing features
- Major worker architecture replacement (in-process worker remains baseline)
---
## Target Architecture Decisions for V1
1. **Keep modular monolith topology** (FastAPI + NiceGUI + in-process worker).
2. **Preserve container-light simplicity guardrails** from `architecture.md`.
3. **Move runtime ownership to lifespan** for:
- DB engine/session factory lifecycle
- Worker runtime resources
- Provider client factory/config lifecycle
4. **Adopt explicit schema bootstrap policy**:
- Dev/test: opt-in auto-bootstrap allowed
- Production: startup must not mutate schema implicitly
5. **Formalize boundary map**:
- Interface (`api`, `ui`) -> Application (`services`) -> Domain (`models/rules`) -> Infrastructure (`db`, `providers`)
- No reverse imports
---
## Detailed Work Breakdown
## Phase A — Architecture Audit and Baseline Freeze
- [ ] **A1. Produce architecture conformance matrix**
- Map each architecture section to current modules/files.
- Classify each row: `aligned`, `partial`, `not aligned`.
- [ ] **A2. Produce REQ-7/REQ-9/REQ-10 focused gap report**
- Explicitly capture current vs required state.
- Include operational risk if left unresolved.
- [ ] **A3. Freeze MVP architecture baseline**
- Record current baseline behavior and known temporary shortcuts.
- Link this baseline from `docs/ver1/ver1.md`.
### Deliverables
- `docs/ver1/ver1-step1-audit.md` (or equivalent section in this doc)
- Architecture conformance table
### Exit Criteria
- No architecture changes begin before gap matrix and baseline are approved.
---
## Phase B — Resource Ownership Consolidation (Lifespan-Centric)
- [ ] **B1. Define runtime resource ownership contract**
- `app.py` lifespan owns resource initialization and cleanup order.
- `app.state` carries resource handles/factories.
- No hidden module-global side-effect initialization for runtime resources.
- [ ] **B2. Refactor DB ownership model**
- Replace module-global engine singleton pattern with lifespan-initialized resource model.
- Define one canonical session-factory access path for app/worker/services.
- [ ] **B3. Normalize worker dependencies**
- Ensure worker uses lifespan-owned resources/factories rather than implicit globals.
- Preserve deterministic startup/shutdown behavior.
- [ ] **B4. Define provider adapter ownership**
- Provider client creation strategy is centralized and lifecycle-aware.
- Avoid per-call hidden client construction when unnecessary.
### MCP-Guided Guardrails
- Use explicit lifecycle composition patterns from `fastapi-async-sqlalchemy-modernization`.
- Maintain app-factory + lifespan structure per `fastapi-uv-docker`.
- Keep UI registration as composition only per `nicegui`.
### Exit Criteria
- Core runtime resources have one owner and one cleanup path.
- No critical resource has ambiguous ownership.
---
## Phase C — Schema Bootstrap Policy (REQ-10 Alignment)
- [ ] **C1. Define environment-aware bootstrap policy**
- `auto_create_schema` (or equivalent) disabled in production by default.
- Startup schema mutation is explicit and intentional.
- [ ] **C2. Split startup responsibilities**
- App startup performs health-critical initialization only.
- Schema bootstrap path is moved to explicit command/flag workflow.
- [ ] **C3. Update deployment/runbook docs**
- Document migration/bootstrap flow for dev, staging, prod.
- Ensure policy is testable and auditable.
### Exit Criteria
- Normal production startup path does not call schema auto-create implicitly.
- Bootstrap behavior is explicit and documented.
---
## Phase D — Module Boundary Enforcement
- [ ] **D1. Publish dependency direction rules**
- Allowed import directions across `api`, `ui`, `services`, `models/domain`, `db/providers`.
- Explicitly disallow reverse dependencies.
- [ ] **D2. Reconcile package map with docs**
- Ensure docs architecture elements match real package layout and naming.
- Update docs where intentional deviations remain.
- [ ] **D3. Isolate cross-layer responsibilities**
- Keep API/UI presentation concerns out of services.
- Keep provider/DB specifics out of interface layer.
- [ ] **D4. Add lightweight architecture checks**
- Add static/import checks and/or review checklist in CI/review process.
### Exit Criteria
- Boundary rules are documented and applied.
- Architectural drift can be detected during review/CI.
---
## Phase E — Architecture Decision Records (ADRs)
- [ ] **E1. Create ADR index**
- Add `docs/adr/README.md` with template and status model.
- [ ] **E2. Record minimum V1 ADR set**
1. Runtime ownership model (lifespan-owned resources)
2. Schema bootstrap policy (explicit vs implicit)
3. Persistence baseline (PostgreSQL target; SQLite transition strategy)
4. Worker topology (in-process for V1, extension path preserved)
- [ ] **E3. Cross-link ADRs**
- Link from architecture and V1 docs.
### Exit Criteria
- Major architecture decisions are explicit, versioned, and discoverable.
---
## Phase F — Documentation Consolidation
- [ ] **F1. Update `docs/architecture.md`**
- Reflect real implementation and V1 target state separately.
- Mark transitional choices clearly.
- [ ] **F2. Update `docs/index.md` navigation consistency**
- Ensure architecture/readme references match actual docs/files.
- [ ] **F3. Update `docs/requirements.md` traceability notes**
- Mark REQ-7/REQ-10 status and verification approach after consolidation.
- [ ] **F4. Add Step 1 result summary**
- Create `docs/ver1/ver1-step1-results.md` after implementation.
### Exit Criteria
- Docs are internally consistent and match runtime architecture reality.
---
## Verification Plan
## Architecture Verification Matrix (Step 1)
1. **Inspection**
- Resource ownership map exists and matches code.
- Schema bootstrap policy is explicit and environment-aware.
- ADRs exist for each key architecture decision.
2. **Automated checks**
- Existing test suite remains green.
- New/updated tests validate startup policy (no implicit schema mutation in production mode).
- Import/dependency-direction checks pass (if introduced).
3. **Demonstration**
- App starts in dev mode with explicit expected behavior.
- App starts in production mode without mutating schema implicitly.
- Worker lifecycle starts/stops cleanly with app lifespan.
---
## Risks and Mitigations
1. **Risk:** Refactor destabilizes MVP behavior
**Mitigation:** Phase changes with small PRs and regression checks after each phase.
2. **Risk:** Over-rotation into premature async rewrite
**Mitigation:** Keep this step focused on lifecycle ownership and boundaries; defer full async migration unless required.
3. **Risk:** Schema policy changes break local DX
**Mitigation:** Keep explicit dev bootstrap path simple and documented.
4. **Risk:** Boundary rules become “doc only”
**Mitigation:** Add CI/review enforcement and architecture checklist.
---
## Recommended Implementation Order
1. Phase A — Audit and baseline freeze
2. Phase B — Resource ownership consolidation
3. Phase C — Schema bootstrap policy
4. Phase D — Boundary enforcement
5. Phase E — ADR authoring
6. Phase F — Documentation consolidation
This order minimizes risk: diagnose first, then refactor ownership, then lock policy, then enforce boundaries, and finally finalize docs.
---
## Step 1 Completion Checklist
- [ ] Architecture conformance matrix approved.
- [ ] REQ-7 ownership gaps resolved or explicitly deferred with owner/date.
- [ ] REQ-10 explicit bootstrap policy implemented and verified.
- [ ] Dependency direction rules documented and enforced.
- [ ] ADR set created for all major Step 1 decisions.
- [ ] Architecture and index docs updated to match implementation.
- [ ] Full test suite passes after consolidation.
- [ ] `docs/ver1/ver1-step1-results.md` created with evidence and residual risks.
---
## Handoff to Step 2
Once Step 1 completes, Step 2 (Error Handling & Reliability Hardening) can proceed on stable architecture seams:
- consistent lifecycle ownership,
- explicit startup policy,
- clear module boundaries,
- documented architecture decisions.
@@ -0,0 +1,42 @@
# Ver1 Step 2 Error-Path Inventory (Carry-Forward)
## Purpose
Provide a compact inventory of major failure paths with taxonomy mapping and retry behavior, aligned with:
- `docs/error_handling.md`
- `docs/ver1/ver1-step2-results.md`
- `docs/ver1/ver1-step1-2-carry-forward-checklist.md` (CF-B1)
This is a lightweight operational artifact for Step 6/7 follow-through.
---
## Inventory Table
| Path ID | Boundary/Operation | Typical Failure Source | Category | Retriable | Surface Behavior | Current Coverage | Notes |
| --- | --- | --- | --- | --- | --- | --- | --- |
| EP-API-001 | API upload request validation | invalid payload / empty file metadata | `validation_error` | no | structured API error envelope (400) | partial | confirm all upload variants |
| EP-API-002 | API resource lookup | missing job/document | `not_found_error` | no | structured API error envelope (404) | partial | verify consistency for all lookup routes |
| EP-SVC-001 | Service provider-call mapping | provider SDK/HTTP failure | `external_provider_error` | sometimes | normalized AppError and safe message | partial | ensure consistent mapping in service boundary tests |
| EP-WKR-001 | Worker provider timeout | timeout/unavailable upstream | `external_provider_error` or `infrastructure_transient_error` | yes | retry or terminal failed with persisted reason | partial | validate category mapping remains deterministic |
| EP-WKR-002 | Worker non-retriable domain/input failure | deterministic invalid input/state | `user_input_error` or `conflict_error` | no | immediate terminal failed with persisted reason | partial | ensure no retry on non-retriable categories |
| EP-WKR-003 | Worker retry exhaustion | repeated retriable failure | category from source; terminal state | capped then no | explicit failed status + error detail | met | implemented in Step 2; keep regression coverage |
| EP-UI-001 | UI upload action failure | surfaced AppError or fallback exception | category-based safe user message | category-driven | title + message + suggestion + error id | partial | verify consistency on all primary UI actions |
| EP-LOG-001 | Cross-boundary error logging | missing/uneven fields | n/a | n/a | logs include `error_id`, `category`, `operation`, ids when available | partial | complete in Step 6 (CF-B2) |
---
## Verification Targets (Step 6/7)
1. Every critical path has category + retriable policy defined.
2. API/UI behavior remains safe and actionable.
3. Worker terminal failures are explicit and persisted.
4. Logging fields are consistent at critical handoffs.
---
## Evidence Links
- Step 2 implementation results: `docs/ver1/ver1-step2-results.md`
- Carry-forward tracking: `docs/ver1/ver1-step1-2-carry-forward-checklist.md`
- Canonical contract: `docs/error_handling.md`
+80
View File
@@ -0,0 +1,80 @@
# Ver1 Step 2 Results: Error Handling & Reliability Hardening
## Summary
Step 2 implementation is complete for the planned reliability and error-handling hardening scope:
1. Worker retries are now explicit, bounded, and category-driven.
2. Error behavior is more consistent across worker/API/UI boundaries.
3. Logging now includes stronger boundary context in key failure paths.
4. Test coverage was expanded for retry policy and new reliability settings.
## Implemented Changes
### 1) Worker retry policy and terminal behavior
- Updated `src/transcription/models.py`:
- Added `Job.retry_count` with default `0`.
- Updated `src/transcription/config.py`:
- Added `worker_max_retries`.
- Added `worker_retry_backoff_seconds`.
- Updated `src/transcription/worker.py`:
- Added bounded retry decision path (`_should_retry`).
- Added requeue behavior (`_requeue_for_retry`) for retriable errors.
- Added deterministic terminal failure behavior (`_finalize_failed_job`).
- Preserved transcript failure detail persistence (`error_id`, `category`, suggestion).
### 2) API fallback normalization hardening
- Updated `src/transcription/api/errors.py`:
- Fallback handler now emits safe generic internal message for unhandled exceptions.
- Added structured boundary logging fields including operation and exception type.
### 3) UI interaction reliability guard
- Updated `src/transcription/ui/upload_page.py`:
- Added duplicate in-flight submission guard to prevent repeated upload handling while busy.
### 4) Observability/logging improvements
- Updated worker logs in `src/transcription/worker.py` to include operation and domain identifiers in key transitions:
- pick
- retry
- transcribed
- failed
## Test Coverage Added/Updated
- Updated `tests/test_models.py`:
- Assert `retry_count` default.
- Updated `tests/test_config.py`:
- Added worker retry settings default test.
- Updated `tests/services/test_worker.py`:
- Added retriable requeue test.
- Added retry-exhaustion terminal failure test.
- Updated existing tests for settings-driven worker behavior.
- Existing API error tests remained green with fallback behavior updates:
- `tests/api/test_error_responses.py`
## Verification Evidence
Executed and passing:
- `uv run pytest tests/services/test_worker.py tests/test_models.py tests/test_config.py tests/api/test_error_responses.py -q`
- `uv run pytest -q`
## Residual Risks / Follow-ups
1. Retry policy currently uses simple fixed backoff; richer strategy (exponential/jitter) can be added in later hardening.
2. Full cross-layer structured logging standardization can be expanded in Step 6 observability work.
3. A formal Step 2 error-path inventory artifact (`ver1-step2-audit.md`) is still recommended for governance completeness.
## Step 2 Exit Assessment
- Error taxonomy and envelope stability: **met**
- Bounded retry and terminal failure behavior: **met**
- Worker reliability controls: **met**
- UI interaction hardening for duplicate actions: **met**
- Test coverage expansion and full-suite regression safety: **met**
Step 2 is complete and ready to hand off to Ver1 Step 3.
+302
View File
@@ -0,0 +1,302 @@
# Step 2 Implementation Plan: Error Handling & Reliability Hardening
## Purpose
Implement **Ver1 Step 2** from `docs/ver1/ver1.md` by standardizing failure behavior and reliability controls so the system fails safely, predictably, and transparently across UI, API, services, worker, and provider boundaries.
Primary governing docs:
- `docs/error_handling.md` (authoritative contract)
- `docs/requirements.md` (REQ-2, REQ-3, REQ-4, REQ-5, REQ-6)
- `docs/architecture.md` (boundary ownership and worker lifecycle)
- `docs/ver1/ver1.md` (Step 2 objective)
---
## MCP Skill and Guide Inputs Incorporated
This plan integrates guidance from john-stream-mcp resources:
1. `resource://skills/python-logging-dictconfig/document`
- centralized `dictConfig` logging
- startup-only configuration
- stable named loggers and boundary-level logging discipline
2. `resource://skills/pytesting/document`
- deterministic, behavior-first tests
- explicit marker usage and fast/slow lane discipline
- integration checks for boundary behavior and error contracts
3. `resource://skills/fastapi-async-sqlalchemy-modernization/document`
- classify at source boundary
- explicit transaction/session behavior under failure
- phased rollout with quality gates and rollback awareness
4. `resource://skills/nicegui-ui-customization/document`
- explicit user-facing error feedback for each interaction
- prevent duplicate actions during in-flight operations
- preserve one-way dependency boundaries from UI -> services
5. `resource://skills/fastapi-uv-docker/document` (applied selectively)
- lifespan-safe startup/shutdown behavior
- health/readiness posture and cloud-native operational checks
---
## Current-State Gap Summary
The project already has a strong baseline (`AppError`, taxonomy enum, API envelope, worker persistence), but Step 2 needs completion-level hardening:
1. **Error contract consistency**
- API envelope exists, but consistency must be verified for all error pathways.
2. **Cross-boundary category normalization**
- Provider/service/worker mappings exist, but require stricter policy checks and tests.
3. **Retry policy implementation depth**
- Step 2 requires bounded retry policy and clear terminal behavior for retriable failures.
4. **Operational traceability**
- Logging exists; Step 2 requires consistent structured fields at critical boundaries.
5. **UI failure UX consistency**
- UI error handling exists; Step 2 requires explicit contract coverage and anti-duplication safeguards.
---
## Scope for Step 2
### In scope
1. Enforce canonical error taxonomy and envelope across all boundaries.
2. Standardize logging fields and boundary-level error traceability.
3. Implement/complete bounded retry and terminal failure behavior in worker paths.
4. Improve UI/API error presentation consistency and actionable guidance.
5. Add comprehensive Step 2 test coverage and verification matrix.
6. Update documentation to reflect final Step 2 policies and behavior.
### Out of scope
- Major architecture/topology changes (external queue, distributed worker)
- New end-user feature expansion outside reliability/error handling
- Full async ORM migration (unless required by bug fix)
---
## Target Decisions for Step 2
1. **Taxonomy stability is mandatory**
- `ErrorCategory` values remain stable contract identifiers.
2. **Classification occurs at source boundary**
- adapters/services normalize early; UI/API only present safely.
3. **User safety over internal detail leakage**
- expose safe message + suggestion + error_id; keep sensitive detail in logs.
4. **Retry is explicit and bounded**
- only retriable categories may retry; retries are capped; terminal failures persist reason.
5. **Boundary logs carry correlation fields**
- include `error_id`, `category`, `operation`, and domain identifiers where available.
---
## Detailed Work Breakdown
## Phase A — Error Contract Audit and Policy Lock
- [ ] **A1. Build error-path inventory**
- Enumerate all failure entry points across:
- `api/`
- `ui/`
- `services/`
- `worker.py`
- `providers/`
- [ ] **A2. Produce taxonomy mapping table**
- For each known exception path, map:
- source exception type
- target `ErrorCategory`
- retriable flag
- API status (if exposed)
- [ ] **A3. Reconcile with `docs/error_handling.md`**
- Resolve any mismatch in category semantics, status codes, or suggested actions.
### Deliverables
- `docs/ver1/ver1-step2-audit.md` (recommended)
- taxonomy mapping table
### Exit Criteria
- Every known failure path has explicit category + retriable policy.
---
## Phase B — API and Service Contract Hardening
- [ ] **B1. Enforce API envelope completeness**
- Ensure all API errors return:
- `error_id`, `category`, `message`, `suggestion`, `timestamp`
- [ ] **B2. Verify category-to-status mapping consistency**
- Confirm `api/errors.py` matches `docs/error_handling.md` mapping guidance.
- [ ] **B3. Normalize service exceptions at boundary**
- Services should raise `AppError` subclasses for known failures.
- Unknown exceptions must become `internal_unexpected_error` with traceable `error_id`.
- [ ] **B4. Ensure safe detail handling**
- API/UI messages remain safe.
- Diagnostic context remains in logs/persisted failure detail where appropriate.
### Exit Criteria
- No unstructured/unclassified exception escapes core boundaries.
- API responses are contract-stable for all tested failure modes.
---
## Phase C — Worker Retry and Terminal Failure Policy
- [ ] **C1. Define bounded retry policy**
- Add configurable retry settings (attempt limit/backoff policy).
- Limit retries to retriable categories.
- [ ] **C2. Implement terminal failure persistence**
- On retry exhaustion, persist clear terminal reason and `error_id`.
- Ensure job status transitions end deterministically at `failed`.
- [ ] **C3. Add duplicate-processing safety checks**
- Prevent duplicate terminal updates when job already resolved.
- [ ] **C4. Validate worker lifecycle under repeated transient failures**
- Ensure loop remains stable and responsive.
### Exit Criteria
- Retries are bounded and policy-driven.
- Exhausted retries produce deterministic failed state with evidence.
---
## Phase D — Logging and Observability Contract Enforcement
- [ ] **D1. Central logging conformance check**
- Confirm startup-only `dictConfig` use remains canonical.
- No module-level `basicConfig` use.
- [ ] **D2. Standardize error log fields**
- Require at minimum when available:
- `error_id`, `category`, `operation`, `exception_type`, `job_id`, `document_id`
- [ ] **D3. Boundary handoff logging**
- Add/normalize logs at transitions:
- UI action -> service
- service -> provider/db
- worker pickup -> terminal state
- [ ] **D4. Log noise control**
- Avoid duplicate stack-trace logging across layers for same exception.
### Exit Criteria
- Critical failure events are traceable end-to-end via logs and `error_id`.
---
## Phase E — UI Error UX Consistency and Interaction Hardening
- [ ] **E1. Standardize user error presentation**
- For upload/jobs interactions, ensure:
- clear title
- plain-language message
- suggested action
- visible error reference id
- [ ] **E2. Add in-flight interaction guards**
- Prevent duplicate submits/click storms during pending operations.
- [ ] **E3. Ensure deterministic UI state recovery**
- controls re-enable after failure
- status text remains actionable
- [ ] **E4. Keep UI boundary clean**
- no provider/protocol details leaked into page modules
### Exit Criteria
- All primary UI actions have consistent success/failure interaction behavior.
---
## Phase F — Test Expansion and Verification
Apply pytesting guidance: behavior-first assertions, deterministic fixtures, strict markers.
- [ ] **F1. API error contract tests**
- verify envelope fields and status mapping for each category class.
- [ ] **F2. Service classification tests**
- verify known failures map to expected `AppError` subclasses/categories.
- [ ] **F3. Worker retry policy tests**
- retriable failure retries and eventual success
- retriable failure exhaustion -> terminal failed
- non-retriable failure -> immediate failed
- [ ] **F4. UI error behavior tests**
- upload/jobs actions show actionable feedback on failures
- duplicate action guard behavior
- [ ] **F5. Regression guard tests**
- at least one test per previously observed production/real-world failure mode
### Validation Commands
- `uv run pytest --collect-only -q`
- `uv run pytest -m unit -q`
- `uv run pytest -m "not external" -q`
- `uv run pytest -q`
### Exit Criteria
- All Step 2 reliability/error contract tests pass.
- Existing suite remains green.
---
## Recommended Implementation Order
1. Phase A — audit and policy lock
2. Phase B — API/service contract hardening
3. Phase C — worker retry and terminal policy
4. Phase D — logging/traceability normalization
5. Phase E — UI consistency hardening
6. Phase F — test expansion and full verification
This order reduces risk by locking policy first, then applying behavior changes at core boundaries before UI polish.
---
## Risks and Mitigations
1. **Risk:** Overly broad retry policy causes hidden failure loops
**Mitigation:** strict category-based retry eligibility + hard cap + terminal persistence.
2. **Risk:** User-facing messages become too technical
**Mitigation:** enforce safe message + suggestion contract in tests.
3. **Risk:** Logging becomes noisy/redundant
**Mitigation:** boundary logging rules and single-trace ownership.
4. **Risk:** Reliability work introduces regressions in happy path
**Mitigation:** run full suite continuously; preserve integration pipeline tests.
---
## Step 2 Completion Checklist
- [ ] Error taxonomy mapping table completed and approved.
- [ ] API envelope and HTTP status behavior verified for all relevant failure categories.
- [ ] Service/provider exception normalization is consistent and tested.
- [ ] Worker retry behavior is bounded, explicit, and terminal-state safe.
- [ ] Structured error logging fields are present at boundary handoffs.
- [ ] UI failure flows provide clear, actionable, and traceable feedback.
- [ ] Full test suite passes with new Step 2 coverage included.
- [ ] `docs/ver1/ver1-step2-results.md` created with evidence and residual risks.
---
## Handoff to Step 3
After Step 2 completion, Step 3 (Functional Completion by Requirement Domain) proceeds on a hardened foundation:
- stable failure contracts,
- predictable retries and terminal behavior,
- actionable user/API error semantics,
- improved diagnostic traceability.
+160
View File
@@ -0,0 +1,160 @@
# Ver1 Step 3 Results: Functional Completion by Requirement Domain
## Summary
Step 3 implementation has been completed for the planned functional-completion scope in a practical personal-scale form.
Implemented in this step:
1. Revision history and acceptance workflows for transcripts.
2. Search over accepted transcript revisions.
3. Export of accepted transcript data.
4. API routes for jobs, revisions, search, and export.
5. UI pathways for revision management, search, and export.
6. Carry-forward integration updates for Step 1/2 follow-ups owned by Step 3.
---
## Implemented Changes
### 1) Data model expansion (functional domain)
Updated `src/transcription/models.py`:
- Added `JobStatus.COMPLETED`.
- Added `TranscriptRevision` table/model:
- `job_id`
- `revision_number`
- `text`
- `source`
- `accepted`
- `created_at`
- Added `Job.revisions` relationship.
This supports immutable revision history and accepted-transcript semantics for search/export.
### 2) Step 3 service layer
Created `src/transcription/services/library.py` with service-backed functional operations:
- `list_jobs(...)`
- `get_job_detail(...)`
- `add_revision(...)`
- `accept_revision(...)`
- `list_revisions(...)`
- `search_accepted_transcripts(...)`
- `export_transcripts(...)`
Key behavior:
- revisions are append-only and incrementing
- accepted revision is unique per job
- accepting a revision syncs canonical transcript and sets job to `completed`
- search scope is accepted revisions only
- export emits deterministic record payloads for archive workflows
### 3) Worker integration for revision provenance
Updated `src/transcription/worker.py`:
- Success path now calls `add_revision(..., source="worker", accepted=False)`.
- Worker still persists canonical transcript and `transcribed` job state.
- Initial machine transcription now appears in revision history.
### 4) API functional completion
Created `src/transcription/api/routes.py` and wired in `src/transcription/app.py`.
New endpoints:
- `GET /api/jobs`
- `GET /api/jobs/{job_id}`
- `GET /api/jobs/{job_id}/revisions`
- `POST /api/jobs/{job_id}/revisions`
- `POST /api/revisions/{revision_id}/accept`
- `GET /api/search?query=...`
- `GET /api/export?accepted_only=true|false`
### 5) UI functional completion
Updated `src/transcription/ui/jobs_page.py`:
- Job detail now includes revision history panel.
- Added user revision submission.
- Added revision accept action.
- Added `/search` page for accepted transcript search.
- Added `/export` page for accepted transcript export preview.
---
## Test Evidence
### Added/Updated Tests
1. `tests/services/test_library.py`
- revision append/accept behavior
- accepted-only search behavior
- export payload behavior
2. `tests/api/test_routes.py`
- jobs/revisions/search/export API serialization and contract behavior
3. `tests/test_models.py`
- `completed` status transition coverage
- `TranscriptRevision` persistence and relationship coverage
4. `tests/services/test_worker.py`
- success-path now verifies initial worker-generated revision persistence
### Full Validation Run
Executed and passing:
- `uv run pytest -q`
---
## Requirement Slice Coverage (Step 3)
| Slice | REQ Coverage | Status | Evidence |
| --- | --- | --- | --- |
| Core lifecycle completion and visibility | REQ-0, REQ-2, REQ-3, REQ-5, REQ-6 | met | worker integration + API/UI jobs routes + tests |
| Revision history and acceptance | REQ-3, REQ-4, REQ-5, REQ-11 | met | `TranscriptRevision`, `services/library.py`, UI revision panel, tests |
| Search over accepted transcripts | REQ-5, REQ-11 | met | `search_accepted_transcripts`, `/api/search`, `/ui/search`, tests |
| Export transcript data | REQ-4, REQ-5, REQ-11 | met | `export_transcripts`, `/api/export`, `/ui/export`, tests |
| Prompt and verbatim flow continuity | REQ-12 | met (continued) | worker transcription flow unchanged in prompt-loading contract |
---
## Carry-Forward Integration Updates
Updated:
- `docs/ver1/ver1-step1-2-carry-forward-checklist.md`
Step 3 updates recorded for:
- CF-A1: in progress with Step 3 inspection evidence
- CF-A3: in progress with boundary-discipline evidence
- CF-C1: done (Step 3 traceability artifacts integrated)
- CF-C2: in progress (routing preserved for later steps)
---
## Residual Follow-ups
1. Step 4: migration rehearsal and rollback runbook execution for schema changes.
2. Step 6/7: broader error-path inventory closure and logging field normalization.
3. Step 9: release readiness reconfirmation for runtime ownership and migration behavior.
---
## Step 3 Exit Assessment
- Requirement-domain functional completion: **met**
- Data integrity and state consistency for new flows: **met**
- API/UI parity for new Step 3 features: **met**
- Test and regression safety: **met**
- Carry-forward integration obligations (Step 3-owned): **met/in progress as routed**
Step 3 is complete and ready to hand off to Step 4.
+433
View File
@@ -0,0 +1,433 @@
# Step 3 Implementation Plan: Functional Completion by Requirement Domain
## Purpose
Implement **Ver1 Step 3** from `docs/ver1/ver1.md` by completing all in-scope V1 functional requirements in a practical, user-first order while preserving:
- personal-scale operation
- single-operator workflow
- private-network deployment assumptions
- low operational overhead
- clean architecture boundaries
Primary governing docs:
- `docs/ver1/ver1.md` (Step 3 objective and sequencing)
- `docs/architecture.md` (module boundaries, workflow, simplicity guardrails)
- `docs/requirements.md` (REQ-0 through REQ-12 traceability)
- `docs/error_handling.md` (error contract across boundaries)
- `docs/intent.md` (verbatim transcription policy and prompt curation)
- `docs/ver1/ver1-step1-2-carry-forward-checklist.md` (Step 1/2 carry-forward integration)
- `docs/ver1/ver1-step2-error-path-inventory.md` (failure-path coverage visibility)
---
## MCP Resources Reviewed and Applied
All resources on `john-stream-mcp` were reviewed. Step 3 applies the following guidance directly:
1. `resource://skills/nicegui/document`
- modular page registration
- one-way dependency flow (`ui/api -> services -> infra`)
- async-first UI responsiveness expectations
2. `resource://skills/nicegui-ui-customization/document`
- reusable UI component extraction for repeated patterns
- in-flight guards and explicit success/failure user feedback
- event-driven updates over ad-hoc polling
3. `resource://skills/fastapi-async-sqlalchemy-modernization/document`
- explicit transaction/session boundaries
- deterministic resource ownership and cleanup continuity from Step 1
- incremental migration strategy with rollback-aware checkpoints
4. `resource://skills/pydantic-settings/document`
- typed configuration as single source of runtime truth
- explicit source precedence and environment-safe defaults
5. `resource://skills/python-logging-dictconfig/document`
- centralized startup-only logging configuration
- named logger discipline and boundary-level structured fields
6. `resource://skills/pytesting/document`
- deterministic test structure and marker discipline
- behavior-first tests with clear fast-path and full-suite validation
7. `resource://skills/fastapi-uv-docker/document`
- health endpoint and runtime startup/shutdown hygiene
- compose/deployment readiness constraints relevant to functional completion
8. `resource://skills/python-typing/document`
- modern typing updates where touched by Step 3 work
9. `resource://skills/ruff-linting-formating/document`
- maintain lint/format consistency in all modified modules
10. `resource://prompts/greenfield-architecture/document`
- explicit staged delivery with tradeoff-aware sequencing and test strategy
11. `resource://prompts/pytest-scaffold/document`
12. `resource://prompts/pytest-fill-scaffold/document`
- structure-first test planning, then deterministic implementation fill-in
Resources reviewed but not directly in Step 3 execution scope (no changes required now):
- `copilot-customization`, `mcp-details`, `vscode-configuration`, `zensical-docs`
- prompts: `authoring`, `mcp-consumer-repo-shim`
---
## Step 3 Success Criteria
Step 3 is complete when:
1. All Step 3-targeted requirement slices are implemented and verified.
2. Functional behavior is available through UI/API where required.
3. Core data integrity and state transitions are deterministic.
4. Error behavior follows `docs/error_handling.md` contracts.
5. Carry-forward Step 1/2 items mapped to Step 3 are updated with evidence.
---
## Requirement-Slice Execution Model (Applied to Every Slice)
For each slice, execute this sequence:
1. Confirm contract/schema and boundary ownership.
2. Implement service/domain logic.
3. Implement persistence/state transitions.
4. Integrate API and/or UI behavior.
5. Add/update unit + integration + targeted end-to-end tests.
6. Update docs and traceability artifacts.
Definition of done per slice:
- behavior is functional
- tests pass in intended marker lanes
- error pathways are classified and surfaced correctly
- requirement traceability is updated with evidence
---
## Detailed Workstreams
## Workstream A — Functional Baseline Audit and Slice Backlog Lock
### Goals
- establish exact Step 3 functional delta from current implementation
- lock a practical slice backlog before coding
### Tasks
1. Build Step 3 requirement matrix (REQ -> current status -> gap -> target slice).
2. Map each gap to one of these domains:
- Upload and lifecycle integrity
- Review and revision history
- Search over accepted transcripts
- Export workflows
- Prompt asset management behavior
- API/UI parity and status visibility
3. Align each slice with architecture boundary ownership and persistence strategy.
4. Link open carry-forward items from checklist:
- CF-A1, CF-A3 (architecture continuity in Step 3)
- CF-C1, CF-C2 (traceability/execution continuity)
### Deliverables
- Step 3 requirement-slice matrix (appendix in this doc or separate artifact)
- prioritized slice backlog with owner and validation method
### Exit Criteria
- every Step 3 slice maps to REQ IDs and a validation method
- no ambiguous ownership remains for in-scope slices
---
## Workstream B — Core End-User Flows (Upload -> Transcribe -> Review)
### Related Requirements
- REQ-0, REQ-1, REQ-2, REQ-3, REQ-4, REQ-5, REQ-6, REQ-12
### Goals
- guarantee end-to-end reliability and usability of the primary user flow
- ensure review experience supports transcript acceptance and correction
### Tasks
1. Validate and close any lifecycle-state gaps:
- enforce valid transitions (`queued -> processing -> transcribed/failed/completed`)
- ensure transition visibility in UI/API
2. Review experience completion:
- transcript detail display stability
- failure detail readability and actionability
- acceptance/edit path for human review
3. Ensure prompt-asset integration remains file-based and auditable:
- one prompt per Markdown file
- prompt selection/usage traceability in job outcomes (if available in model)
4. Confirm worker/UI interactions remain responsive under long-running jobs:
- in-flight guards
- clear status refresh behavior
### Deliverables
- complete end-user flow behavior with stable lifecycle visibility
- test coverage for happy path and failure path
### Exit Criteria
- user can run upload -> process -> review reliably
- failed and successful outcomes are both actionable and traceable
---
## Workstream C — Revision History and Provenance Completion
### Related Requirements
- REQ-3, REQ-4, REQ-5, REQ-11
### Goals
- finalize immutable transcript revision behavior and provenance consistency
### Tasks
1. Define/confirm revision invariants:
- append-only revision history
- clear current/accepted revision indicator
2. Persist revision events consistently through service layer boundaries.
3. Ensure UI/API expose revision timeline and selected revision details.
4. Align error handling for revision conflicts and missing resources.
### Deliverables
- revision-history feature completeness
- provenance and history read-path coverage
### Exit Criteria
- transcript edits produce deterministic revision records
- previous revisions remain inspectable
---
## Workstream D — Search Completion (Accepted Transcript Scope)
### Related Requirements
- REQ-0, REQ-5, REQ-11
### Goals
- provide practical search over accepted transcripts for personal corpus usage
### Tasks
1. Finalize searchable scope and indexing rules (accepted/current text only).
2. Implement service-backed search query behavior.
3. Expose search in UI/API with clear result metadata (document/job/revision context).
4. Add guardrails for empty/no-result/error scenarios with actionable messaging.
### Deliverables
- functional search pathway with deterministic results for accepted text
### Exit Criteria
- operator can find transcripts reliably by text queries
- no-result and error states are clear and non-silent
---
## Workstream E — Export Completion
### Related Requirements
- REQ-0, REQ-4, REQ-5, REQ-11
### Goals
- deliver practical export of transcript data for personal archive use
### Tasks
1. Finalize export contract (format, included fields, scope filters).
2. Implement export service with deterministic data mapping.
3. Add UI/API trigger path and user-visible completion/failure feedback.
4. Validate export integrity against persisted source-of-record entities.
### Deliverables
- end-to-end export capability with operator-visible outcomes
### Exit Criteria
- export output is complete, consistent, and usable for downstream personal archive workflows
---
## Workstream F — API/UI Parity and Interaction Hardening
### Related Requirements
- REQ-5 plus cross-cutting REQ-2/3/4
### Goals
- ensure UI and API expose coherent feature behavior and error contracts
### Tasks
1. Verify API/UI parity matrix for each Step 3 slice.
2. Standardize interaction behavior:
- loading and in-flight states
- success/failure notifications
- stable error_id visibility where user-facing
3. Ensure route/page modules remain composition-focused (business logic in services).
### Deliverables
- API/UI parity checklist with resolved gaps
### Exit Criteria
- no major flow exists in one interface with conflicting semantics in the other
---
## Workstream G — Carry-Forward Integration During Step 3
### Goals
- close Step 1/2 follow-ups that are Step 3-owned
### Tasks
1. Update checklist item CF-A1 as Step 3 slices touch runtime resources.
2. Update checklist item CF-A3 with lightweight boundary enforcement evidence.
3. Update CF-C1/CF-C2 traceability mapping with Step 3 outcomes.
### Deliverables
- updated `docs/ver1/ver1-step1-2-carry-forward-checklist.md` evidence entries
### Exit Criteria
- Step 3-owned carry-forward items are either completed or explicitly routed with evidence
---
## Test and Validation Plan
Apply `pytesting` guidance with deterministic, behavior-focused coverage.
### Validation Lanes
1. Structure/collection:
- `uv run pytest --collect-only -q`
2. Fast feedback lane:
- `uv run pytest -m unit -q`
3. Main verification lane:
- `uv run pytest -m "not external" -q`
4. Full suite:
- `uv run pytest -q`
### Required Coverage Areas
- lifecycle transition invariants
- revision history invariants
- search query behavior and result mapping
- export integrity and failure handling
- UI interaction guards and actionable failure feedback
- API envelope and status consistency for new/changed flows
### Test Design Rules
- one behavior target per test
- minimize heavy mocking; prefer real-path behavior checks where practical
- keep markers explicit and strict
---
## Logging, Error, and Config Guardrails for Step 3 Changes
1. Logging
- keep centralized startup logging config (`dictConfig`) as canonical
- include required error fields at boundary failures (`error_id`, `category`, `operation`, identifiers where available)
2. Error handling
- preserve taxonomy stability from `docs/error_handling.md`
- map any new failure pathways into existing categories
- surface actionable suggestions in UI/API
3. Configuration
- use typed settings and avoid ad-hoc env reads in business modules
- keep environment behavior explicit and documented
---
## Implementation Order (Detailed)
1. Workstream A: audit and backlog lock
2. Workstream B: core flow completion
3. Workstream C: revision/provenance completion
4. Workstream D: search completion
5. Workstream E: export completion
6. Workstream F: API/UI parity hardening
7. Workstream G: carry-forward integration updates
8. Full validation pass + docs/traceability updates
---
## Deliverables
1. Step 3 requirement-slice matrix with REQ mapping and evidence links
2. implemented Step 3 functional slices across service/persistence/API/UI
3. updated tests and passing validation lanes
4. updated carry-forward checklist entries (`CF-A1`, `CF-A3`, `CF-C1`, `CF-C2` as applicable)
5. Step 3 results document (`docs/ver1/ver1-step3-results.md`)
---
## Risks and Mitigations
1. **Risk:** Scope creep from optional enhancements during feature completion
- **Mitigation:** enforce REQ-mapped slice backlog and defer non-REQ enhancements
2. **Risk:** Functional parity drift between UI and API
- **Mitigation:** maintain parity matrix and verify both surfaces per slice
3. **Risk:** Data-model changes introduce migration surprises
- **Mitigation:** coordinate with Step 4 runbook expectations early and test on representative data
4. **Risk:** Reliability regressions while adding functionality
- **Mitigation:** run full error-path regression checks and keep Step 2 contracts intact
---
## Step 3 Completion Checklist
- [ ] Step 3 requirement-slice matrix completed and linked to REQ IDs.
- [ ] Core end-user flow is functionally complete and verified.
- [ ] Revision history/provenance behavior is complete and test-covered.
- [ ] Search over accepted transcripts is complete and test-covered.
- [ ] Export flow is complete and test-covered.
- [ ] API/UI parity checklist has no unresolved high-impact gaps.
- [ ] Step 3-owned carry-forward items are updated with evidence.
- [ ] Validation lanes pass (`collect-only`, unit, non-external, full).
- [ ] `docs/ver1/ver1-step3-results.md` is created with evidence and residual follow-ups.
---
## Handoff to Step 4
Step 3 completion enables Step 4 (Data Model and Migration Safety) with:
- finalized functional domain behavior
- stable persistence expectations
- traceable requirement evidence
- clarified migration-impact surface
+113
View File
@@ -0,0 +1,113 @@
# Ver1 Step 4 Migration and Rollback Runbook
## Purpose
Provide a concise, operator-safe procedure for schema migration execution,
compatibility validation, and rollback/mitigation for personal-scale deployments.
This runbook supports `docs/ver1/ver1-step4.md` and REQ-10 by keeping normal
production startup non-mutating unless explicitly configured otherwise.
---
## Preconditions
1. Application version to deploy is known and checked out.
2. `.env` values are configured for target environment.
3. Database backup path is prepared.
4. Application process is stopped before migration on production-like systems.
---
## Commands
Use explicit migration runner operations:
1. List pending migrations:
- `uv run python -m transcription.migration_runner --list`
2. Apply pending migrations:
- `uv run python -m transcription.migration_runner --apply`
3. Validate schema compatibility:
- `uv run python -m transcription.migration_runner --check`
Recommended execution order:
1. `--list`
2. backup database
3. `--apply`
4. `--check`
5. start application
---
## Backup Procedure (SQLite Baseline)
For SQLite deployments, copy the DB file before migration:
- Example DB path default: `./transcription.db`
- Keep timestamped backup copy in a safe location.
If the file is in active use, stop the app first.
---
## Verification Checklist
After migration apply:
1. `--check` exits successfully.
2. `schema_migration_history` includes applied revisions.
3. Application starts successfully.
4. Health endpoint responds: `/healthz`.
5. Critical flows smoke-check:
- upload
- job processing
- revision listing/acceptance
---
## Rollback and Mitigation Decision Tree
1. If migration fails before changes commit:
- fix issue
- re-run apply
2. If migration partially applied or compatibility check fails:
- stop app
- restore from backup
- investigate and produce forward-fix migration if needed
3. If app starts but functional invariants fail:
- stop app
- restore backup
- add corrective migration/backfill and rehearse before retry
For this Step 4 baseline, backup restore is the primary rollback mechanism.
---
## Failure Classification Guidance
Classify migration failures using `docs/error_handling.md` categories:
- transient connection issues -> `infrastructure_transient_error`
- permissions/misconfiguration -> `infrastructure_persistent_error`
- unexpected migration logic defects -> `internal_unexpected_error`
Record failure details with operation context and timestamp.
---
## Operational Notes
- `migration_auto_apply_on_startup` defaults to `False`.
- `validate_schema_on_startup` defaults to `True`.
- Startup schema validation fails fast on incompatibility.
This protects production from accidental schema drift.
---
## Post-Step-4 Follow-Up
If migration complexity grows beyond lightweight revision scripts,
introduce a dedicated migration framework in a future step while preserving
this runbook structure and operator-first workflow.
+153
View File
@@ -0,0 +1,153 @@
# Ver1 Step 4 Results: Data Model and Migration Safety
## Summary
Step 4 implementation status: **complete (baseline scope)**.
This document records completed migration-safety work, validation evidence, and remaining follow-ups for Ver1 Step 4.
Implemented in this step:
1. Added explicit migration framework module with revision history tracking.
2. Added schema compatibility validation and startup guardrails.
3. Added migration runner CLI for list/apply/check operations.
4. Added migration tests and Step 4 validation evidence.
5. Added Step 4 migration/rollback runbook.
---
## Implemented Changes
### 1) Schema audit and invariant lock
Implemented read-only compatibility checks in `src/transcription/db.py`:
- `validate_schema_compatibility(...)` verifies required V1 tables:
- `document`
- `job`
- `transcript`
- `transcriptrevision`
- verifies required `job.retry_count` column
- returns explicit issue identifiers (non-mutating check)
### 2) Migration policy/tooling lock
Added explicit migration revision model in `src/transcription/migrations.py`:
- `MigrationRevision` dataclass
- ordered `MIGRATIONS` registry
- migration history table: `schema_migration_history`
- explicit pending-list and apply operations
### 3) Forward migration implementation
Implemented two baseline forward migrations:
1. `0001_add_retry_count_to_job`
2. `0002_create_transcriptrevision_table`
Each migration is idempotent and recorded in migration history.
### 4) Rollback and mitigation runbook
Created `docs/ver1/ver1-step4-migration-runbook.md` with:
- preconditions
- list/apply/check command sequence
- backup-first procedure
- verification checklist
- rollback/mitigation decision tree
- error classification guidance aligned to `docs/error_handling.md`
### 5) Backfill implementation or explicit no-backfill decision
No backfill required for this baseline Step 4 scope.
Rationale:
- additive migration operations only
- default values and new-table creation do not require historical row rewrites for current V1 invariants
- residual advanced backfill scenarios deferred unless future schema evolution introduces incompatible transforms
---
## Test and Verification Evidence
### Added/Updated Tests
1. `tests/test_migrations.py`
- pending migration discovery
- migration apply + history recording
- idempotent re-apply behavior
2. `tests/test_db.py`
- compatibility-check behavior on fresh schema
- table expectation updates for `transcriptrevision`
3. `tests/test_config.py`
- migration safety setting defaults
4. `tests/test_app.py`
- lifespan test compatibility with migration/validation startup hooks
### Validation Runs
Run and record outcomes:
- `uv run pytest --collect-only -q` -> passed
- `uv run pytest -m unit -q` -> passed
- `uv run pytest -m "not external" -q` -> passed
- `uv run pytest -q` -> passed
### Migration Rehearsal Evidence
Migration rehearsal details (test-based):
- baseline data set used: in-memory SQLite legacy-shaped schema fixture (`job` table missing Step 4 additions)
- forward migration result: pending revisions applied successfully (`0001`, `0002`)
- post-migration verification result: schema checks pass and migration history recorded
- rollback/mitigation rehearsal result: runbook defined backup-restore primary rollback class for personal-scale SQLite deployment
---
## Requirement Traceability (Step 4)
| Step 4 Area | REQ Coverage | Status | Evidence |
| --- | --- | --- | --- |
| Schema lifecycle and state persistence safety | REQ-3, REQ-4, REQ-11 | met | `src/transcription/migrations.py`, `tests/test_migrations.py`, `tests/test_db.py` |
| Lifespan/runtime ownership continuity | REQ-7 | met | `src/transcription/app.py` startup checks + existing lifespan ownership model |
| Explicit non-mutating production startup policy | REQ-10 | met | `migration_auto_apply_on_startup=False` default + explicit runner workflow + startup validation gate |
| Prompt/data continuity constraints | REQ-12 | met (continued) | no prompt-contract mutation in Step 4 changes |
---
## Operational Artifacts Produced
- `docs/ver1/ver1-step4.md`
- `docs/ver1/ver1-step4-migration-runbook.md`
- `src/transcription/migrations.py`
- `src/transcription/migration_runner.py`
- README migration workflow updates
---
## Risks, Exceptions, and Follow-Ups
1. This lightweight migration system is appropriate for current personal-scale scope but may require a dedicated framework as schema complexity grows.
2. Rollback remains backup-restore primary; reversible down-migration coverage is intentionally limited in this baseline.
3. Startup compatibility checks currently fail fast with generic runtime error text and can be further normalized under API/operator error envelopes in later hardening.
Open follow-ups to carry forward:
- Evaluate migration framework escalation criteria in Step 9/10 readiness updates.
- Add optional richer structured migration logging fields if observability scope expands.
---
## Step 4 Exit Assessment
- Schema validation against finalized V1 domain: **met**
- Forward migration path safety and repeatability: **met (baseline scope)**
- Rollback/mitigation readiness: **met (backup-restore primary path)**
- Backfill risk closure: **met (no backfill required for current deltas)**
- Test and regression safety: **met**
Step 4 completion status: **complete (baseline scope)**
---
## Handoff to Step 5
Once Step 4 is marked complete, Step 5 can proceed with:
- verified migration safety baseline
- explicit rollback and recovery procedures
- reduced data-integrity risk entering private-network safety hardening
+378
View File
@@ -0,0 +1,378 @@
# Step 4 Implementation Plan: Data Model and Migration Safety
## Purpose
Implement **Ver1 Step 4** from `docs/ver1/ver1.md` by making data-model evolution safe, explicit, and repeatable for personal-scale deployment.
Step 4 ensures schema changes are handled through deterministic migration workflows rather than implicit startup mutation, while preserving:
- personal-scale operational simplicity
- single-operator deployment model
- lifecycle-owned runtime resource boundaries
- stable requirement traceability and low rollback risk
Primary governing docs:
- `docs/ver1/ver1.md` (Step 4 objective and sequencing)
- `docs/architecture.md` (runtime ownership, persistence boundaries, simplicity guardrails)
- `docs/requirements.md` (REQ-3, REQ-4, REQ-7, REQ-10, REQ-11, REQ-12 emphasis)
- `docs/error_handling.md` (failure classification and safe error surfacing)
- `docs/intent.md` (verbatim/transcription/revision domain behavior)
---
## MCP Resources Reviewed and Applied
All currently available resources on `john-stream-mcp` were reviewed. Step 4 applies the following guidance directly:
1. `resource://skills/fastapi-async-sqlalchemy-modernization/document`
- explicit engine/session lifecycle ownership
- transaction boundary clarity for schema transitions and backfills
- phased rollout with rollback-aware checkpoints
2. `resource://skills/pydantic-settings/document`
- typed migration/runtime safety settings
- explicit source-precedence behavior for operational toggles
- fail-fast config semantics for unsafe startup paths
3. `resource://skills/pytesting/document`
- deterministic migration verification lanes
- strict marker discipline
- behavior-first test coverage for migration outcomes
4. `resource://skills/python-logging-dictconfig/document`
- startup-centralized logging configuration
- structured migration and rollback event traceability
5. `resource://skills/fastapi-uv-docker/document`
- deployment and rehearsal discipline
- startup/health posture validation during migration windows
6. `resource://skills/python-typing/document`
- modern typing hygiene for touched migration/persistence modules
7. `resource://skills/ruff-linting-formating/document`
- lint/format consistency for migration scripts and database modules
Planning methodology inputs also applied:
8. `resource://prompts/greenfield-architecture/document`
- staged execution with explicit risk and extension handling
9. `resource://prompts/pytest-scaffold/document`
10. `resource://prompts/pytest-fill-scaffold/document`
- test-structure-first and deterministic fill-in sequencing
Reviewed but not directly Step 4 execution-critical:
- skills: `copilot-customization`, `mcp-details`, `nicegui`, `nicegui-ui-customization`, `vscode-configuration`, `zensical-docs`
- prompts: `authoring`, `mcp-consumer-repo-shim`
---
## Current-State Gap Summary (Step 4 Scope)
Based on Step 13 outcomes and current docs/tests:
1. **Bootstrap policy baseline is present**
- Environment-aware schema bootstrap policy exists and aligns with REQ-10 intent.
2. **Functional model expanded in Step 3**
- Revision/acceptance features introduce schema evolution requirements that need formal migration safety rehearsal.
3. **Runbook maturity required**
- Step 4 requires explicit migration + rollback procedures and evidence.
4. **Backfill risk must be evaluated**
- New/changed fields and semantics must be checked for historical data reconciliation needs.
5. **Release-path integration needed**
- Step 4 artifacts must feed Step 9 release readiness and Step 10 docs completion.
---
## Scope for Step 4
### In scope
1. Validate final V1 schema against implemented domain behavior (post-Step 3 reality).
2. Define and implement forward-safe migration path for expected upgrades.
3. Define and document rollback/mitigation strategy for migration failures.
4. Implement backfill scripts only if required, with idempotent behavior.
5. Rehearse migration + rollback locally using representative sample data.
6. Add Step 4-specific verification tests and operational checks.
7. Produce operator-facing migration/rollback runbook and Step 4 results evidence.
### Out of scope
- Distributed/externally orchestrated migration systems
- Major persistence-architecture rewrites beyond V1 scope
- Non-V1 enhancement migrations unrelated to implemented requirement slices
---
## Target Decisions for Step 4
1. **Production startup remains non-mutating by default**
- Preserve REQ-10 posture and avoid implicit schema mutation at normal startup.
2. **Schema changes are explicit operator workflows**
- Migrations run as deliberate operational actions, not hidden side effects.
3. **Migration safety beats migration speed**
- Additive and reversible-first patterns are preferred where possible.
4. **Rollback policy is explicit per change**
- Each migration must declare rollback class:
- direct rollback supported
- forward-fix required
- backup restore required
5. **Backfills are optional and minimal**
- Introduce only when required by correctness/invariants, never by convenience.
6. **Migration observability is mandatory**
- Structured logs include operation, migration identifier, status, and failure classification.
---
## Detailed Work Breakdown
## Phase A — Schema and Domain Invariant Audit
- [ ] **A1. Build canonical V1 schema inventory**
- Enumerate all persisted entities and key fields:
- document records
- jobs and statuses
- transcripts
- transcript revisions
- failure/provenance fields
- [ ] **A2. Validate invariants against implemented behavior**
- Cross-check Step 3 functionality and current domain expectations:
- append-only revision history
- accepted revision semantics
- canonical transcript synchronization behavior
- [ ] **A3. Classify required schema deltas**
- Categorize deltas:
- additive and safe
- compatibility-sensitive
- potentially destructive (must be staged or deferred)
### Deliverables
- `docs/ver1/ver1-step4-schema-audit.md` (recommended)
- schema-delta matrix with risk class and owning module
### Exit Criteria
- all required schema changes have explicit rationale and risk classification
- no ambiguous domain invariant remains
---
## Phase B — Migration Policy and Tooling Lock
- [ ] **B1. Lock migration workflow policy**
- Define canonical migration execution path and artifact conventions.
- [ ] **B2. Define migration authoring checklist**
- Include:
- preconditions
- forward steps
- rollback class
- post-verification checks
- [ ] **B3. Align policy with runtime startup safeguards**
- Ensure production startup remains explicit/non-mutating by default.
- [ ] **B4. Define operator invocation standard**
- One documented command path for local and production-like workflows.
### Deliverables
- migration policy section (this doc + runbook)
- migration authoring/review checklist
### Exit Criteria
- one unambiguous migration process exists and is documented
- startup policy and migration policy are consistent and non-conflicting
---
## Phase C — Forward Migration Implementation
- [ ] **C1. Implement required migration set**
- Build migration artifacts for all approved Step 4 deltas.
- [ ] **C2. Preserve compatibility where needed**
- Use staged expand/contract strategy when direct cutover is unsafe.
- [ ] **C3. Add migration logging checkpoints**
- Log start, phase boundaries, completion, and failure details.
- [ ] **C4. Verify post-migration schema state**
- Confirm expected tables/columns/constraints/indexes are present.
### Deliverables
- migration artifacts/scripts for V1 target schema
- schema verification checklist outputs
### Exit Criteria
- baseline-to-target forward migration executes successfully
- post-migration checks pass deterministically
---
## Phase D — Rollback and Mitigation Strategy
- [ ] **D1. Define rollback classes per migration**
- direct downgrade vs forward-fix vs backup-restore.
- [ ] **D2. Create rollback decision tree**
- trigger conditions, safe stop points, and recovery path.
- [ ] **D3. Align failure classification with `error_handling.md`**
- normalize migration failures into canonical categories:
- `infrastructure_transient_error`
- `infrastructure_persistent_error`
- `internal_unexpected_error` (as needed)
- [ ] **D4. Rehearse rollback flow**
- run at least one migration failure simulation and execute chosen recovery path.
### Deliverables
- rollback/mitigation decision tree
- rehearsal evidence notes
### Exit Criteria
- operator can execute rollback/mitigation without undocumented steps
- migration failure paths are diagnosable and classified
---
## Phase E — Backfill Decision and Execution (Conditional)
- [ ] **E1. Determine backfill necessity**
- inspect whether existing records violate new invariants.
- [ ] **E2. If required, implement idempotent backfill**
- resumable, batch-safe, and deterministic update semantics.
- [ ] **E3. Add post-backfill verification**
- validate:
- revision sequencing integrity
- accepted/current transcript consistency
- job lifecycle consistency
- [ ] **E4. If not required, record explicit “no backfill needed” evidence**
### Deliverables
- backfill script(s) and checklist (if applicable)
- no-backfill rationale artifact (if not applicable)
### Exit Criteria
- required backfills completed and verified OR formally ruled out with evidence
---
## Phase F — Verification and Test Expansion
Apply `pytesting` guidance (deterministic, behavior-first, strict markers).
- [ ] **F1. Migration application tests**
- verify forward migration from representative baseline.
- [ ] **F2. Post-migration schema contract tests**
- verify expected schema shape and key constraints.
- [ ] **F3. Rollback/mitigation tests**
- verify chosen rollback class behavior where practical.
- [ ] **F4. Startup policy regression tests**
- confirm production-mode startup does not mutate schema implicitly.
- [ ] **F5. Backfill behavior tests (if applicable)**
- idempotency and invariants after repeated execution.
### Validation Commands
- `uv run pytest --collect-only -q`
- `uv run pytest -m unit -q`
- `uv run pytest -m "not external" -q`
- `uv run pytest -q`
### Exit Criteria
- all Step 4 migration-safety checks pass
- no REQ-10 regression introduced
---
## Phase G — Runbook and Documentation Closure
- [ ] **G1. Create migration and rollback runbook**
- include:
- prerequisites
- backup step
- migration execution
- verification
- rollback/mitigation
- [ ] **G2. Update traceability artifacts**
- map Step 4 outcomes to REQ IDs and evidence.
- [ ] **G3. Prepare Step 4 handoff artifacts**
- ensure outputs feed Step 9 release readiness and Step 10 docs completion.
### Deliverables
- `docs/ver1/ver1-step4-migration-runbook.md` (recommended)
- `docs/ver1/ver1-step4-results.md`
- updated traceability references where needed
### Exit Criteria
- migration operations are executable using docs alone
- Step 4 evidence is complete and auditable
---
## Recommended Implementation Order
1. Phase A — schema/invariant audit
2. Phase B — migration policy and tooling lock
3. Phase C — forward migration implementation
4. Phase D — rollback/mitigation strategy + rehearsal
5. Phase E — backfill decision and execution (conditional)
6. Phase F — test and verification expansion
7. Phase G — runbook + traceability closure
This sequence minimizes risk by locking policy and scope before irreversible data changes.
---
## Risks and Mitigations
1. **Risk:** Data loss from unsafe schema transitions
- **Mitigation:** backup-first gate, staged migration strategies, post-check verification.
2. **Risk:** Startup policy drift reintroduces implicit schema mutation
- **Mitigation:** explicit regression tests for production startup behavior (REQ-10 guard).
3. **Risk:** Rollback path is incomplete or untested
- **Mitigation:** mandatory rollback class declaration + rehearsal evidence.
4. **Risk:** Backfill scripts cause partial/inconsistent state
- **Mitigation:** idempotent design, batching, and invariant-focused verification.
5. **Risk:** Migration failure diagnostics are unclear
- **Mitigation:** structured logging + error category mapping per `error_handling.md`.
---
## Step 4 Completion Checklist
- [ ] V1 schema audit completed and approved.
- [ ] Migration workflow policy is locked and documented.
- [ ] Required forward migrations are implemented and validated.
- [ ] Rollback/mitigation decision tree is documented and rehearsed.
- [ ] Backfill required/not-required decision is evidenced.
- [ ] Migration-safety test coverage is added and passing.
- [ ] Startup non-mutation policy remains verified in production mode.
- [ ] Step 4 runbook and results artifacts are completed.
---
## Handoff to Step 5
Step 4 completion enables Step 5 (Private-Network Safety Baseline) with:
- stable, explicit schema evolution mechanics
- reduced upgrade risk for single-operator deployments
- migration/rollback procedures suitable for personal-scale production
- traceable evidence for release-readiness gates
+178
View File
@@ -0,0 +1,178 @@
# Ver1 Step 5 Results: Private-Network Safety Baseline
## Summary
Step 5 implementation status: **complete**.
This document records completed private-network safety controls, validation evidence, and residual risks for Ver1 Step 5.
Implemented in this step:
1. Added private-network security assumptions and control matrix (`docs/ver1/ver1-step5-security-assumptions.md`).
2. Implemented optional single-operator access control for `/ui*` and `/api*` via HTTP Basic auth.
3. Added upload-size guardrails (`MAX_UPLOAD_BYTES`) and config fail-fast validation for operator credential requirements.
4. Hardened unexpected-error user-facing messaging to reduce sensitive detail leakage.
5. Added Step 5 tests for access control, security settings, and upload size boundaries.
6. Executed dependency/security scans (`pip-audit`, `bandit`) with no critical/high findings.
---
## Implemented Changes
### 1) Security assumptions and threat model
Completed.
- Added `docs/ver1/ver1-step5-security-assumptions.md` defining:
- trusted private-network deployment assumptions
- single-operator usage model
- explicit out-of-scope classes (enterprise IAM, internet-facing zero-trust, multi-tenant controls)
- Added Step 5 control/ownership matrix and residual-risk notes.
### 2) Single-operator access control baseline
Completed.
- New module: `src/transcription/security.py`
- `is_protected_path(...)` protects `/ui*` and `/api*`
- `enforce_request_access(...)` enforces optional operator auth
- robust Basic auth parsing and safe denial responses via `AccessDeniedError`
- App middleware added in `src/transcription/app.py`:
- enforces auth on protected paths
- returns consistent `401` envelope and `WWW-Authenticate: Basic` for denied requests
- Health endpoint `/healthz` remains intentionally unauthenticated.
### 3) Input validation and safe-output hardening
Completed baseline.
- `src/transcription/services/upload.py`
- added size-based validation guard (`max_upload_bytes`)
- emits `user_input_error` with actionable guidance on over-limit uploads
- `src/transcription/errors.py`
- `classify_unexpected_error(...)` now returns operation-only message without embedding raw exception text
- preserves traceability via existing `error_id` and taxonomy while reducing accidental sensitive leak risk
### 4) Secret handling and configuration safety
Completed baseline.
- `src/transcription/config.py` additions:
- `max_upload_bytes` (default `15 * 1024 * 1024`)
- `operator_access_enabled` (default `False`)
- `operator_username` (default `operator`)
- `operator_password` (optional, required when auth enabled)
- Added settings validator enforcing fail-fast config safety:
- raises validation error if `OPERATOR_ACCESS_ENABLED=true` and `OPERATOR_PASSWORD` unset
- `README.md` updated with Step 5 security env settings and explicit secret-handling guidance.
### 5) Dependency/security scanning baseline
Completed.
- Dependency vulnerability scan:
- `uvx pip-audit`
- Result: **No known vulnerabilities found**
- Static security scan:
- `uvx bandit -r src/transcription`
- Result: **No issues identified** (0 low/medium/high)
---
## Test and Verification Evidence
### Added/Updated Tests
1. `tests/api/test_access_control.py`
- unauthorized protected API denied (`401` + challenge)
- invalid credentials denied
- valid credentials accepted
- `/ui` protected when auth enabled
- `/healthz` remains unprotected
2. `tests/services/test_upload.py`
- added rejection test for payloads above `MAX_UPLOAD_BYTES`
3. `tests/test_config.py`
- added security defaults assertions
- added fail-fast assertion for missing `OPERATOR_PASSWORD` when auth enabled
4. `tests/test_errors.py`
- updated expectations for sanitized unexpected-error message behavior
5. Updated integration expectations where failure detail should no longer include raw exception text:
- `tests/services/test_worker.py`
- `tests/integration/test_pipeline_flow.py`
6. `tests/test_app.py` updated for new middleware wiring.
### Validation Runs
Run and record outcomes:
- `uv run pytest --collect-only -q` -> passed
- `uv run pytest -m unit -q` -> passed
- `uv run pytest -m "not external" -q` -> passed
- `uv run pytest -q` -> passed
### Security Scan Evidence
Record scan commands and outcomes:
- dependency scan command(s): `uvx pip-audit`
- static/security lint command(s): `uvx bandit -r src/transcription`
- critical/high findings: none
- remediation/defer decisions: no remediations required for Step 5 baseline
---
## Requirement Traceability (Step 5)
| Step 5 Area | REQ Coverage | Status | Evidence |
| --- | --- | --- | --- |
| Private-network and single-operator safety posture | REQ-9 | met | `docs/ver1/ver1-step5-security-assumptions.md`, README security section |
| Access control behavior at UI/API boundaries | REQ-5, REQ-7 | met | `src/transcription/security.py`, `src/transcription/app.py`, `tests/api/test_access_control.py` |
| Input validation and safe user-facing error behavior | REQ-1, REQ-2, REQ-5 | met | `src/transcription/services/upload.py`, `src/transcription/errors.py`, updated tests |
| Config and startup safety controls | REQ-8, REQ-10 | met | `src/transcription/config.py`, `tests/test_config.py`, `README.md` |
| Persistence and domain integrity continuity | REQ-11, REQ-12 | met (no regressions) | full test lane pass including integration and worker flows |
---
## Operational Artifacts Produced
- `docs/ver1/ver1-step5.md`
- `docs/ver1/ver1-step5-results.md`
- `docs/ver1/ver1-step5-security-assumptions.md`
- `src/transcription/security.py`
- `tests/api/test_access_control.py`
---
## Risks, Exceptions, and Follow-Ups
1. Basic auth is intentionally right-sized for trusted private-network use; if deployment posture changes, stronger identity controls are required.
2. Current model remains single shared operator credential (no per-user audit identity).
3. No built-in brute-force/rate-limit controls in Step 5 scope; evaluate in future hardening if threat model expands.
Open follow-ups to carry forward:
- Consider stronger auth/session model if system becomes multi-user or internet-accessible.
- Consider request throttling/rate limiting if threat model changes.
---
## Step 5 Exit Assessment
- Private-network assumptions and controls: **met**
- Access-control baseline effectiveness: **met**
- Validation and safe-output safety: **met (baseline)**
- Secret handling and config safety: **met**
- Dependency/security risk closure: **met (no critical/high findings)**
- Test and regression safety: **met**
Step 5 completion status: **complete**
---
## Handoff to Step 6
Once Step 5 is marked complete, Step 6 can proceed with:
- clearer operational security assumptions for logs/runbooks
- hardened boundary behavior for diagnosis and support
- reduced risk posture for personal-scale ongoing operations
@@ -0,0 +1,51 @@
# Ver1 Step 5 Security Assumptions (Private-Network Baseline)
## Operating Model
This system is operated as:
1. single operator
2. trusted private network
3. non-public deployment (no direct internet exposure for UI/API)
Out of scope for Step 5:
- enterprise IAM/SSO/RBAC
- internet-facing zero-trust edge controls
- multi-tenant user isolation
## Step 5 Controls and Ownership
| Control | Boundary Owner | Verification |
| --- | --- | --- |
| Optional operator authentication for `/ui*` and `/api*` routes | `src/transcription/security.py`, `src/transcription/app.py` | `tests/api/test_access_control.py` |
| Unauthorized contract (`401` + safe envelope + `WWW-Authenticate`) | `src/transcription/api/errors.py` | `tests/api/test_access_control.py` |
| Upload size guard (`MAX_UPLOAD_BYTES`) | `src/transcription/services/upload.py`, `src/transcription/config.py` | `tests/services/test_upload.py` |
| Fail-fast auth config when enabled | `src/transcription/config.py` | `tests/test_config.py` |
| Safe unexpected error messaging (reduced leak surface) | `src/transcription/errors.py` | `tests/test_errors.py`, worker/integration failure tests |
## Access-Control Policy (Step 5)
- Health endpoint (`/healthz`) remains unauthenticated for operability checks.
- When `OPERATOR_ACCESS_ENABLED=true`, protected paths require HTTP Basic auth:
- `/ui`
- `/ui/...`
- `/api/...`
- Credentials are runtime-configured:
- `OPERATOR_USERNAME` (default `operator`)
- `OPERATOR_PASSWORD` (required when access is enabled)
## Secrets Policy
- Secrets must be provided via runtime environment variables.
- Secrets must not be committed to source control.
- Secrets must not be logged.
- Example secret values in docs must always be placeholders.
## Residual Risks (Accepted for Step 5)
1. HTTP Basic credentials are suitable only for trusted private-network deployment.
2. No per-user identity model (single shared operator credential).
3. No advanced brute-force/rate-limit controls in Step 5 scope.
These are carried forward for future hardening only if deployment posture changes.
+459
View File
@@ -0,0 +1,459 @@
# Step 5 Implementation Plan: Private-Network Safety Baseline
## Purpose
Implement **Ver1 Step 5** from `docs/ver1/ver1.md` by applying right-sized security controls for a single-user system running on a trusted private network.
Step 5 focuses on practical risk reduction without introducing unnecessary complexity, while preserving:
- personal-scale operational simplicity
- single-operator workflow
- explicit boundary ownership from `docs/architecture.md`
- safety and diagnostics behavior defined in `docs/error_handling.md`
Primary governing docs:
- `docs/ver1/ver1.md` (Step 5 objective and sequencing)
- `docs/architecture.md` (deployment model and module boundaries)
- `docs/error_handling.md` (safe user output and diagnostic boundaries)
- `docs/requirements.md` (REQ-1, REQ-2, REQ-5, REQ-7, REQ-8, REQ-9, REQ-10, REQ-11, REQ-12)
- `docs/intent.md` (domain integrity priorities)
---
## MCP Resources Reviewed and Applied
All currently available resources on `john-stream-mcp` were reviewed. Step 5 applies the following guidance directly:
1. `resource://skills/pydantic-settings/document`
- typed security-related runtime settings
- explicit env/source precedence
- fail-fast handling for missing/invalid required values
2. `resource://skills/fastapi-uv-docker/document`
- environment and deployment safety defaults
- startup/health posture and container hygiene assumptions
- local secret handling expectations
3. `resource://skills/pytesting/document`
- deterministic security-behavior test lanes
- marker discipline and behavior-first assertions
4. `resource://skills/python-logging-dictconfig/document`
- centralized logging discipline
- avoid leaking sensitive values in logs
5. `resource://skills/nicegui-ui-customization/document`
- user-safe failure messaging in UI
- resilient interaction behavior and clear error feedback
6. `resource://skills/ruff-linting-formating/document`
- keep lint quality baseline stable during safety changes
Planning methodology input:
7. `resource://prompts/greenfield-architecture/document`
- explicit tradeoff-oriented staging
- scope discipline for minimally sufficient security controls
Reviewed but not directly Step 5 execution-critical:
- skills: `copilot-customization`, `fastapi-async-sqlalchemy-modernization`, `mcp-details`, `nicegui`, `python-typing`, `vscode-configuration`, `zensical-docs`
- prompts: `authoring`, `mcp-consumer-repo-shim`, `pytest-scaffold`, `pytest-fill-scaffold`
---
## Current-State Gap Summary (Step 5 Scope)
Based on current implementation and prior Step outputs:
1. **Private-network assumptions are implicit, not fully codified**
- Need explicit, documented security posture and operator constraints.
2. **Access control for UI/API is minimal or absent**
- Step 5 requires basic single-operator gating appropriate for private-network use.
3. **Input validation baseline exists but needs security-oriented audit closure**
- Upload and API validation should be verified for abuse-resistant boundaries.
4. **Safe error output baseline exists (Step 2), but needs security confirmation pass**
- Must ensure no sensitive internals leak through API/UI error payloads.
5. **Secret handling documentation needs formalization in Step 5 artifacts**
- Local workflow should clearly prohibit secrets in repo-tracked files and logs.
6. **Dependency/security scanning is not yet formalized as a recurring gate**
- Step 5 requires lightweight scanning and triage of high-risk findings.
---
## Scope for Step 5
### In scope
1. Codify private-network and single-operator security assumptions in docs and config.
2. Add basic access control for UI/API actions (right-sized for trusted network model).
3. Audit and harden input-validation boundaries (upload, API params/payloads, operational flags).
4. Verify safe error surface behavior (UI/API) and prevent sensitive leak paths.
5. Formalize local secret handling policy and usage examples.
6. Add lightweight dependency/security scan workflow and triage policy.
7. Add Step 5 verification tests and results artifact.
### Out of scope
- Internet-facing zero-trust security architecture
- Enterprise IAM/SSO/role systems
- Full cryptographic key-management infrastructure
- Major security product integrations beyond lightweight V1 needs
---
## Target Decisions for Step 5
1. **Threat model is explicitly private-network + single operator**
- Security controls are right-sized to this posture and documented as assumptions.
2. **Access control is required, even in private network mode**
- Basic gate (single shared operator credential/token) protects UI/API mutation paths.
3. **Validation and output safety are strict defaults**
- Reject invalid inputs early; never expose sensitive internals in user-facing outputs.
4. **Secrets are runtime-only**
- No secrets committed to source control; no plaintext secret logging.
5. **Security scanning is lightweight but mandatory**
- Add recurring dependency/security checks with high-risk triage and closure workflow.
6. **No security control may violate Step 14 operational simplicity guardrails**
- Preserve deployability and maintainability for personal-scale use.
---
## Detailed Work Breakdown
## Phase A — Security Posture Definition and Gap Lock
- [ ] **A1. Define Step 5 threat model**
- trusted private network
- single operator
- local deployment assumptions
- explicit out-of-scope threat classes
- [ ] **A2. Produce security baseline checklist**
- access control
- validation boundaries
- safe error behavior
- secret handling
- dependency risk checks
- [ ] **A3. Map controls to architecture boundaries**
- UI
- API
- service
- config/runtime
- operator runbooks
### Deliverables
- `docs/ver1/ver1-step5-security-assumptions.md` (recommended)
- Step 5 control matrix (control -> owner -> validation method)
### Exit Criteria
- private-network safety posture is explicit and approved
- each in-scope control has boundary ownership and verification path
---
## Phase B — Basic Single-Operator Access Control
- [ ] **B1. Select access mechanism**
- minimal approach suitable for private-network model
- explicitly document tradeoffs and operator ergonomics
- [ ] **B2. Protect mutating operations first**
- upload/create/accept/export-trigger endpoints
- UI actions that trigger persistence changes
- [ ] **B3. Protect read operations as policy requires**
- determine read-path gating expectations and apply consistently
- [ ] **B4. Add clear unauthorized behavior contract**
- stable API status and safe message
- UI feedback with actionable operator guidance
### Deliverables
- access-control policy and implementation notes
- unauthorized behavior matrix (UI/API)
### Exit Criteria
- unauthorized actions are blocked consistently
- authorized operator flows remain usable and deterministic
---
## Phase C — Input Validation and Safe Output Hardening
- [ ] **C1. Validation audit for all entry points**
- file uploads (type/size/content guards)
- route/query/body constraints
- service-layer invariants
- [ ] **C2. Normalize validation failures to canonical taxonomy**
- `validation_error` vs `user_input_error` consistency
- [ ] **C3. Confirm safe error output policy under security lens**
- no stack traces/secrets/internal paths in UI/API default outputs
- preserve error reference IDs for traceability
- [ ] **C4. Add abuse-resistant guardrails where practical**
- basic request-size and payload-shape constraints
- anti-duplication interaction safeguards (where missing)
### Deliverables
- validation-path inventory and hardening checklist
- safe-output verification notes
### Exit Criteria
- input boundaries are deterministic and tested
- user-facing error outputs remain safe and actionable
---
## Phase D — Secrets Handling and Configuration Safety
- [ ] **D1. Define secret handling policy**
- where secrets are allowed (runtime env only)
- where secrets are prohibited (source files, docs examples beyond placeholders)
- [ ] **D2. Enforce settings expectations**
- required secret fields fail fast
- avoid fallback defaults that silently weaken safety
- [ ] **D3. Add operator documentation for local secret workflow**
- how to set environment values safely
- how to rotate/update credentials locally
- [ ] **D4. Validate logging does not leak secret values**
- startup/config logs
- error logs for provider/config failures
### Deliverables
- secret-handling section in runbook/README/docs
- settings and logging safety verification notes
### Exit Criteria
- no secret leakage paths remain in normal operations
- operator can configure secrets safely using docs only
---
## Phase E — Dependency and Security Scanning Baseline
- [ ] **E1. Select lightweight scanning commands for V1**
- dependency vulnerability scan
- optional static security scan if practical
- [ ] **E2. Define triage policy for findings**
- severity classification
- required closure criteria for Step 5 completion
- [ ] **E3. Run scans and capture evidence**
- record command outputs/summaries
- remediate or formally defer with risk notes
- [ ] **E4. Add recurring execution guidance**
- local pre-release checklist integration
- future CI gate handoff for Step 7/9
### Deliverables
- Step 5 scan report artifact (recommended)
- triage log of resolved/deferred findings
### Exit Criteria
- no unresolved critical vulnerabilities in Step 5 scope
- high-risk findings are resolved or explicitly risk-accepted with rationale
---
## Phase F — Verification and Test Expansion
Apply `pytesting` guidance (deterministic, behavior-first, strict markers).
- [ ] **F1. Access-control tests**
- unauthorized requests are rejected as expected
- authorized operator requests succeed
- [ ] **F2. Validation and abuse-boundary tests**
- invalid payloads rejected with stable category/status
- file-type/size constraints enforced
- [ ] **F3. Safe-output tests**
- API/UI error responses avoid sensitive details
- error IDs and suggestions remain present
- [ ] **F4. Config/secret safety tests**
- required secrets fail fast when missing
- no unsafe fallback behavior introduced
### Validation Commands
- `uv run pytest --collect-only -q`
- `uv run pytest -m unit -q`
- `uv run pytest -m "not external" -q`
- `uv run pytest -q`
### Exit Criteria
- Step 5 safety behavior is test-covered and passing
- no regression in core upload/transcribe/review workflows
---
## Phase G — Documentation and Risk Closure
- [ ] **G1. Create Step 5 results artifact**
- `docs/ver1/ver1-step5-results.md`
- [ ] **G2. Update operator-facing docs**
- security assumptions and local deployment cautions
- credential handling and recovery basics
- [ ] **G3. Update traceability and carry-forward notes**
- map Step 5 controls to REQ and evidence
### Deliverables
- `docs/ver1/ver1-step5-results.md`
- updated security assumptions checklist and risk summary
### Exit Criteria
- Step 5 controls and residual risks are fully documented
- handoff is ready for Step 6 observability and Step 7 quality gates
---
## Recommended Implementation Order
1. Phase A — posture definition and gap lock
2. Phase B — access control baseline
3. Phase C — validation/output hardening
4. Phase D — secrets and config safety
5. Phase E — dependency/security scan baseline
6. Phase F — test expansion and verification
7. Phase G — docs and risk closure
This order reduces risk by locking assumptions first, then applying controls at highest-impact boundaries before final verification and documentation.
---
## Step 5 Execution Checklist (Phase-by-Phase)
Use this checklist to execute Step 5 in implementation order and record progress/evidence.
### Phase A — Security Posture Definition and Gap Lock
- [ ] Publish `docs/ver1/ver1-step5-security-assumptions.md`.
- [ ] Record explicit in-scope and out-of-scope threat classes.
- [ ] Produce Step 5 control matrix (control, owner, validation method).
- [ ] Confirm boundary ownership for each control (UI/API/service/config/docs).
### Phase B — Basic Single-Operator Access Control
- [ ] Choose and document access mechanism (with rationale and tradeoffs).
- [ ] Implement enforcement for mutating API operations.
- [ ] Implement corresponding UI-side access behavior for protected actions.
- [ ] Decide and enforce read-path protection policy.
- [ ] Add unauthorized API/UI contract tests.
### Phase C — Input Validation and Safe Output Hardening
- [ ] Complete input-validation inventory for upload/API/service boundaries.
- [ ] Tighten payload/file constraints where gaps are found.
- [ ] Ensure validation failure categories match `docs/error_handling.md`.
- [ ] Verify user-facing errors remain safe, actionable, and traceable.
- [ ] Add regression tests for invalid/boundary inputs.
### Phase D — Secrets Handling and Configuration Safety
- [ ] Document secrets policy (runtime-only, no repo storage).
- [ ] Verify required secret settings fail fast when missing.
- [ ] Audit logs for accidental secret leakage risk paths.
- [ ] Update operator docs for local secret setup/rotation workflow.
- [ ] Add tests for config safety expectations where practical.
### Phase E — Dependency and Security Scanning Baseline
- [ ] Select scanning commands and record tool versions.
- [ ] Run baseline scans and capture outputs.
- [ ] Triage findings by severity and exploitability in private-network context.
- [ ] Resolve/mitigate critical findings; document accepted residual risk.
- [ ] Add recurring scan guidance for release workflow handoff.
### Phase F — Verification and Test Expansion
- [ ] Run `uv run pytest --collect-only -q`.
- [ ] Run `uv run pytest -m unit -q`.
- [ ] Run `uv run pytest -m "not external" -q`.
- [ ] Run `uv run pytest -q`.
- [ ] Confirm no regressions in upload/transcribe/review core flows.
### Phase G — Documentation and Risk Closure
- [ ] Complete `docs/ver1/ver1-step5-results.md` with evidence.
- [ ] Update docs/README/runbooks with final Step 5 security posture.
- [ ] Record REQ traceability updates and residual risks.
- [ ] Confirm Step 5 completion checklist items are all closed.
---
## Risks and Mitigations
1. **Risk:** Over-engineering beyond private-network needs
- **Mitigation:** enforce Step 5 scope discipline and threat-model constraints.
2. **Risk:** Access controls disrupt operator usability
- **Mitigation:** keep mechanism minimal and test primary workflows thoroughly.
3. **Risk:** Sensitive details leak through errors/logging
- **Mitigation:** apply safe-output and log-sanitization checks with tests.
4. **Risk:** Unpatched dependency vulnerabilities remain invisible
- **Mitigation:** formalize scan + triage + evidence capture workflow.
5. **Risk:** Secret handling remains ad hoc
- **Mitigation:** fail-fast settings + explicit operator documentation + review checks.
---
## Step 5 Completion Checklist
- [ ] Private-network and single-operator security assumptions are documented.
- [ ] Basic single-operator access control is implemented and verified.
- [ ] Input-validation boundaries are audited, hardened, and test-covered.
- [ ] UI/API error output safety is confirmed under security tests.
- [ ] Secret handling policy and local workflow docs are complete.
- [ ] Dependency/security scans are run; critical findings are resolved.
- [ ] Step 5 tests pass across all validation lanes.
- [ ] `docs/ver1/ver1-step5-results.md` is completed with evidence and residual risks.
---
## Handoff to Step 6
Step 5 completion enables Step 6 (Minimal Observability & Operability) with:
- explicit security assumptions for operator context
- access and validation controls suitable for private-network operation
- safer runtime/configuration handling for ongoing operations
- dependency-risk visibility feeding release-readiness gates
+206
View File
@@ -0,0 +1,206 @@
## Step 6 Goal (from `docs/ver1/ver1.md`)
Implement **minimal observability & operability** so a single operator can quickly diagnose and recover from common failures.
---
## 1) Current-State Assessment (what already exists)
### Already in place
- Central startup logging initialization via `setup_logging()` and `dictConfig` (`src/transcription/config.py`, `src/transcription/app.py`).
- Error taxonomy and `error_id` envelope contract (`src/transcription/errors.py`) aligned with `docs/error_handling.md`.
- Error handling for API and worker includes category + error IDs in some paths (`src/transcription/api/errors.py`, `src/transcription/worker.py`).
- Basic health endpoint `/healthz` (`src/transcription/api/health.py`).
- UI error display already shows actionable message + error reference (`src/transcription/ui/error_presenter.py`).
### Gaps to close for Step 6
1. **Structured logging is inconsistent** (many logs are free-form text with embedded key/value; no enforced schema).
2. **Boundary coverage is incomplete** (UI/service/API/worker dont all emit consistent operation logs).
3. `/healthz` is very basic; no lightweight readiness/startup diagnostics endpoint/reporting.
4. No concise **operator runbook** yet (start/stop, log interpretation, recovery playbooks).
5. Minimal counters/timings are not yet standardized.
---
## 2) MCP Guidance Incorporated (relevant items)
From `john-stream-mcp`, these are directly applied:
- **`python-logging-dictconfig`**: keep one centralized `dictConfig`, configure once at startup, named loggers in modules.
- **`fastapi-async-sqlalchemy-modernization`**: include observability + health/readiness checks; explicit lifecycle and deterministic startup/shutdown checks.
- **`fastapi-uv-docker`**: keep `/healthz`; add practical readiness/ops checks for deployment clarity.
- **`pytesting`**: deterministic tests, concise structure, validation lanes (`collect-only`, `unit`, `not external`, full).
- **`pydantic-settings`**: keep typed settings as single source for logging/health behavior flags.
- **`nicegui` + `nicegui-ui-customization`**: preserve clear, actionable user-facing error feedback and non-blocking UI flows.
- **`zensical-docs`**: produce focused, navigable operator docs.
(Other MCP resources were reviewed but are not core to Step 6 implementation scope.)
---
## 3) Detailed Implementation Plan for Step 6
## Workstream A — Structured Logging Contract
### A1. Define a canonical log event schema
Create a project log schema (doc + code-level constants) with required keys:
- `timestamp` (UTC)
- `level`
- `logger`
- `operation`
- `event`
- `error_id` (when error)
- `category` (when error)
- `exception_type` (when error)
- `job_id`, `document_id` (when relevant)
- optional: `duration_ms`, `retry_count`, `status`
### A2. Standardize log emission helpers
Add small logging helpers (or adapter utilities) to reduce drift:
- `log_operation_start(...)`
- `log_operation_success(...)`
- `log_operation_error(...)`
Keep this minimal and avoid heavy observability frameworks.
### A3. Update formatter to structured output
Use `dictConfig` to emit either:
- JSON lines (preferred for structure), or
- strict key-value line format with fixed fields.
**Recommendation:** JSON lines to satisfy “structured logging” unambiguously while still simple.
---
## Workstream B — Boundary-by-Boundary Instrumentation
### B1. API boundary (`src/transcription/api/*`)
- Add request-level operation logs for key routes (`upload.submit`, `jobs.list`, `jobs.get`, etc.).
- Ensure API exception handler logs always include `error_id`, `category`, `operation`, `exception_type`.
### B2. Service boundary (`src/transcription/services/*`)
- Add operation logs around:
- upload validation/persist,
- transcription orchestration,
- revision add/accept,
- search/export.
- Add timing (`duration_ms`) for high-value operations only.
### B3. Worker boundary (`src/transcription/worker.py`)
- Standardize all worker log events to schema.
- Ensure retry logs include: `retriable`, `retry_count`, `max_retries`, `backoff_seconds`.
- Ensure terminal failure logs include error contract fields.
### B4. UI boundary (`src/transcription/ui/*`)
- Keep user-safe UI messages as-is.
- Add backend/UI logger events for user-triggered failures (operation + error_id + category) so UI-visible errors correlate to server logs.
---
## Workstream C — Health, Readiness, Startup Operability
### C1. Keep `/healthz` lightweight
- Return “process is running” status quickly.
### C2. Add lightweight `/readyz`
Include small checks:
- DB connectivity ping.
- Worker thread alive check.
- Optional prompt directory existence check.
Return structured status payload with per-check pass/fail.
### C3. Startup self-check summary log
At startup, emit one concise ops summary event:
- environment
- schema validation result
- worker started
- directories checked
- bootstrap/migration mode flags
---
## Workstream D — Minimal Counters & Timings
Add only high-value diagnostics:
1. `worker_jobs_processed_total`
2. `worker_jobs_failed_total`
3. `worker_retries_total`
4. `transcription_duration_ms` (per job)
5. `upload_persist_duration_ms` (per upload path)
Implementation can be log-derived counters (no external metrics backend required).
---
## Workstream E — Operator Runbook
Create concise runbook doc (recommended: `docs/ver1/ver1-step6-operator-runbook.md`) with:
1. **Start/Stop**
- local `uv` run mode
- docker compose mode (if applicable)
2. **Where logs are**
- stdout, docker logs commands, filtering by `error_id` / `operation`.
3. **Common failure patterns → recovery**
- provider timeout
- auth denied
- missing prompt dir
- DB unavailable
- job stuck/failed with retry exhausted
4. **Recovery procedures**
- restart sequence
- verify health/readiness
- when to requeue/re-upload
5. **Escalation artifacts**
- capture timestamp + error_id + operation + job_id/document_id
Also update `README.md` with short links to the runbook.
---
## Workstream F — Verification & Quality Gates
### Tests to add/update
- `tests/api/test_health.py`
- `/healthz` baseline
- `/readyz` pass/fail behavior
- `tests/api/test_error_responses.py` / `tests/api/test_routes.py`
- logs include `error_id/category/operation` on failures
- `tests/services/test_worker.py`
- retry/failure log fields + timing presence
- `tests/ui/*`
- ensure UI error correlation path includes operation/ref id behavior
### Validation commands (per MCP pytest guidance)
- `uv run pytest --collect-only -q`
- `uv run pytest -m unit -q`
- `uv run pytest -m "not external" -q`
- `uv run pytest -q`
---
## 4) Traceability to Governing Docs
- **`docs/ver1/ver1.md` Step 6:** all 5 implementation bullets covered.
- **`docs/error_handling.md`:** logging contract fields and error taxonomy continuity enforced.
- **`docs/architecture.md`:** respects modular boundaries, in-process worker model, low-complexity ops.
- **`docs/requirements.md`:**
- REQ-8 (startup logging/config centralization) strengthened,
- REQ-5 (status visibility) improved operationally,
- REQ-7 lifecycle ownership observability improved.
- **`docs/intent.md`:** keeps operation simple for personal-scale archival workflow.
---
## 5) Suggested Execution Order (low risk)
1. Logging schema + formatter + helpers
2. Worker/API instrumentation (highest value)
3. Service/UI instrumentation
4. `/readyz` + startup summary check
5. Runbook + README links
6. Tests + Step 6 results artifact (`docs/ver1/ver1-step6-results.md`)
+322
View File
@@ -0,0 +1,322 @@
# Version 1 Implementation Plan
This plan defines the path from MVP to **Version 1 complete**.
The objective is to deliver the full scoped product with readiness for reliable personal-scale operation, while explicitly separating refinements/enhancements into a future document.
---
## 0) Plan Governance & Scope Control (Foundation)
**Goal:** Keep execution focused on V1 completion and avoid unnecessary process overhead.
### Implementation Steps
1. Create and maintain a **V1 Traceability Matrix**:
- Requirement ID
- Current status (`done`, `partial`, `not started`)
- Validation method
2. Define V1 completion gates:
- Functional complete
- Operationally complete
- Personal-deployment ready
3. Snapshot the MVP baseline (tag/changelog reference).
4. Keep a standing rule: non-V1 ideas go to a separate enhancements backlog, and enter V1 only by explicit approval.
### Deliverables
- `docs/ver1/ver1.md` (this plan)
- V1 traceability artifact:
- `docs/ver1/ver1-step1-2-carry-forward-checklist.md`
- `docs/ver1/ver1-step2-error-path-inventory.md` (supporting artifact)
### Exit Criteria
- Every in-scope requirement has explicit status and validation evidence.
- Scope-change discipline is followed consistently.
---
## 1) Architecture Consolidation
**Goal:** Align implementation with intended architecture while preserving simplicity.
### Implementation Steps
1. Compare implemented modules/components with architecture documentation.
2. Identify and classify architectural debt:
- Temporary coupling
- Missing interfaces
- Placeholder services/components
3. Resolve architecture gaps that threaten reliability, maintainability, or clear boundaries.
4. Record material decisions and tradeoffs in ADRs.
### Deliverables
- Updated architecture diagrams and boundaries
- ADR entries for material decisions
### Exit Criteria
- Architecture documentation reflects system reality.
- High-impact architecture risks are addressed or explicitly scheduled.
---
## 2) Error Handling & Reliability Hardening
**Goal:** Ensure predictable, diagnosable behavior under expected failure conditions.
### Implementation Steps
1. Apply the canonical taxonomy and response model from `docs/error_handling.md` across UI/API/service/worker boundaries.
2. Ensure clear distinction between:
- User-facing safe messages
- Internal diagnostic detail
- Retryable vs non-retryable failures
3. Implement practical resilience controls where needed:
- Timeouts
- Bounded retries with backoff
- Explicit terminal failure states
4. Add failure-path tests for critical workflows.
### Deliverables
- Error handling reference aligned with `docs/error_handling.md`
- Failure-mode test coverage for critical paths
### Exit Criteria
- Error behavior is consistent across major flows.
- Known failure scenarios are tested and pass.
- Failed jobs include actionable, traceable failure detail.
---
## 3) Functional Completion by Requirement Domain
**Goal:** Complete all V1 requirements in a practical, user-first order.
### Recommended Order
1. End-user core flows (upload → transcribe → review)
2. Data integrity and persistence behavior
3. Minimal operator controls needed for personal use
4. In-scope UX quality improvements
### Implementation Steps
For each requirement slice:
1. Confirm contract/schema
2. Implement service/domain logic
3. Implement persistence/state transitions
4. Integrate API/UI behavior
5. Add or update automated tests
6. Update relevant docs
### Deliverables
- Requirement completion report with validation evidence linked to REQ IDs
### Exit Criteria
- All V1 must-have requirements are complete and verified.
---
## 4) Data Model and Migration Safety
**Goal:** Keep schema evolution safe and simple for personal-scale deployment.
### Implementation Steps
1. Validate schema against finalized V1 domain needs.
2. Implement forward-safe migrations for expected upgrades.
3. Define a simple rollback/mitigation path for migration failures.
4. Add backfill scripts only where truly required.
5. Rehearse migration + rollback locally using representative sample data.
### Deliverables
- Migration and rollback runbook
- Backfill checklist (if applicable)
### Exit Criteria
- Migration path is tested and documented.
- No unresolved data-loss risk for V1 upgrade.
---
## 5) Private-Network Safety Baseline
**Goal:** Apply right-sized security controls for a single-user system on a trusted private network.
### Implementation Steps
1. Enforce private-network deployment assumptions in docs and configuration.
2. Ensure basic single-operator access control for UI/API actions.
3. Enforce input validation and safe error output behavior.
4. Keep secrets out of source control; document local secret handling.
5. Run lightweight dependency/security scanning and resolve high-risk findings.
### Deliverables
- Security assumptions checklist (private network, single operator)
- Basic risk update for V1 scope
### Exit Criteria
- No unresolved critical vulnerabilities.
- Access behavior and validation rules are verified for intended operating model.
---
## 6) Minimal Observability & Operability
**Goal:** Keep operation and troubleshooting simple, clear, and reliable.
### Implementation Steps
1. Standardize structured logging across UI/API/service/worker boundaries.
2. Ensure logged errors include category and error reference IDs per `error_handling.md`.
3. Add lightweight health/startup checks.
4. Document a concise operator runbook:
- start/stop
- log locations
- common failure patterns and recovery steps
5. Add minimal counters/timings only where they clearly improve diagnosis.
### Deliverables
- Logging and error-traceability baseline
- Operator runbook
### Exit Criteria
- Operator can diagnose common failures using logs + runbook.
- System recovery procedures are documented and repeatable.
---
## 7) Test Coverage and Practical Quality Gates
**Goal:** Prevent regressions in critical flows without overbuilding test infrastructure.
### Implementation Steps
1. Expand unit and integration tests for all V1 requirement slices.
2. Add end-to-end tests for critical journeys:
- upload
- process/transcribe
- view result
- failure visibility
3. Add targeted contract tests where adapter boundaries are error-prone.
4. Keep CI gates focused on high-value checks (tests, lint, type checks, dependency scan).
### Deliverables
- V1 test matrix mapped to requirements and critical flows
- CI quality-gate checklist
### Exit Criteria
- Critical-path regressions are automatically detected.
- Test suite gives consistent release confidence for personal-scale operation.
---
## 8) Performance Validation for Personal Scale
**Goal:** Confirm acceptable responsiveness for expected personal-use workload.
### Implementation Steps
1. Define practical performance expectations for key flows.
2. Run representative tests using real document samples.
3. Address obvious bottlenecks in queries, file handling, or worker concurrency.
4. Document known limits and expected operating bounds.
### Deliverables
- Short performance validation note
- Known-limits summary
### Exit Criteria
- Core flows remain responsive for expected corpus size and usage patterns.
---
## 9) Release Readiness and Environment Simplicity
**Goal:** Make deployment and rollback repeatable for a single-operator Docker Compose setup.
### Implementation Steps
1. Define a simple release checklist:
- run tests
- run one end-to-end transcription check
- verify migration compatibility
2. Document environment configuration requirements clearly.
3. Validate deployment and rollback steps in a local rehearsal.
4. Add backup/restore verification for core persisted data.
### Deliverables
- Release checklist
- Environment and rollback guide
### Exit Criteria
- Deployment/rollback is rehearsed and documented.
- Operator can release safely without hidden steps.
---
## 10) Documentation Completion
**Goal:** Ensure V1 can be built, operated, and supported from documentation.
### Implementation Steps
1. Update core project docs to match final V1 behavior:
- Architecture
- Error handling
- Requirements status
- Index/navigation
- Intent alignment summary
2. Add operator troubleshooting guides.
3. Add integration/API examples for the operator and future maintainers.
4. Publish changelog/version notes for V1.
### Deliverables
- Updated documentation set for V1
- V1 release notes
### Exit Criteria
- A future maintainer can run and support the system using docs alone.
---
## 11) Final Validation and Launch
**Goal:** Confirm V1 readiness and launch with low operational risk.
### Implementation Steps
1. Run end-to-end acceptance validation against the V1 traceability matrix.
2. Complete operator acceptance checks on representative real documents.
3. Execute launch checklist (including backup, migration, and rollback readiness).
4. Launch and monitor logs/status closely during initial use.
### Deliverables
- Acceptance validation record
- Launch checklist completion record
### Exit Criteria
- V1 requirements are validated.
- Initial launch behavior is stable and recoverable.
---
## 12) Post-Launch Stabilization
**Goal:** Address early issues quickly and lock in a reliable V1 baseline.
### Implementation Steps
1. Track defects and operational pain points observed after launch.
2. Prioritize short-cycle stabilization fixes.
3. Remove temporary launch-only workarounds when safe.
4. Capture a brief retrospective and update the next-phase backlog.
### Deliverables
- Stabilization summary
- Updated backlog for post-V1 enhancements
### Exit Criteria
- Major launch issues are resolved.
- System transitions to steady personal-use operation.
---
## Recommended Execution Rhythm
- **Weekly:** Requirement closure + risk review
- **As needed (small batch releases):** Run release checklist and deploy
- **Milestone check-ins:** After phases 2, 6, 9, and 11
---
## Scope Discipline Rule (V1 Focus)
To preserve delivery focus:
- V1 execution prioritizes completion of scoped requirements.
- Refinements/enhancements are captured in a separate future document and backlog.
- Only explicitly approved scope changes may enter this plan.
-176
View File
@@ -1,176 +0,0 @@
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
-1
View File
@@ -1 +0,0 @@
# {{project_name}}
-11
View File
@@ -1,11 +0,0 @@
project_name:
type: str
help: What is the project name?
repo_name:
type: str
help: What is the repo name?
module_name:
type: str
help: What is your Python module name?
-1
View File
@@ -1 +0,0 @@
# {{project_name}}
-11
View File
@@ -1,11 +0,0 @@
[project]
name = "{{repo_name}}"
version = "0.1.0"
requires-python = ">=3.12"
[dependency-groups]
dev = [
"pre-commit>=4.6.0",
"ruff>=0.15.15",
"ipykernel>=7.2.0",
]
-60
View File
@@ -1,60 +0,0 @@
line-length = 120
indent-width = 4
target-version = "py313"
exclude = [
".venv",
".devenv",
".git",
".vscode",
"build",
"site",
"__pycache__",
]
[lint]
extend-select = [
"ARG", # https://docs.astral.sh/ruff/rules/#flake8-unused-arguments-arg
"B", # https://docs.astral.sh/ruff/rules/#flake8-bugbear-b
"C4", # https://docs.astral.sh/ruff/rules/#flake8-comprehensions-c4
"DOC102", # https://docs.astral.sh/ruff/rules/docstring-extraneous-parameter/
"DOC202", # https://docs.astral.sh/ruff/rules/docstring-extraneous-returns/
"DOC403", # https://docs.astral.sh/ruff/rules/docstring-extraneous-yields/
"DOC502", # https://docs.astral.sh/ruff/rules/docstring-extraneous-exception/
"E", "W", # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w
"F", # https://docs.astral.sh/ruff/rules/#pyflakes-f
"FURB", # https://docs.astral.sh/ruff/rules/#refurb-furb
"I", # https://docs.astral.sh/ruff/rules/#isort-i
"N", # https://docs.astral.sh/ruff/rules/#pep8-naming-n
"PD", # https://docs.astral.sh/ruff/rules/#pandas-vet-pd
"PTH", # https://docs.astral.sh/ruff/rules/#flake8-use-pathlib-pth
"UP", # https://docs.astral.sh/ruff/rules/#pyupgrade-up
"SIM", # https://docs.astral.sh/ruff/rules/#flake8-simplify-sim
"PLR0202", # https://docs.astral.sh/ruff/rules/no-classmethod-decorator/
"PLR0203", # https://docs.astral.sh/ruff/rules/no-staticmethod-decorator/
"PLR0206", # https://docs.astral.sh/ruff/rules/property-with-parameters/
"PLR0915", # https://docs.astral.sh/ruff/rules/too-many-statements/
"PLR1702", # https://docs.astral.sh/ruff/rules/too-many-nested-blocks/
"TRY002",
]
extend-fixable = ["ALL"]
ignore = [
"UP046",
"UP047",
]
[lint.extend-per-file-ignores]
"*.ipynb" = [
"F401", # unused imports
"F841", # unused local variable
]
[lint.isort]
force-single-line = true
[format]
quote-style = "double"
indent-style = "space"
skip-magic-trailing-comma = false
line-ending = "auto"
-350
View File
@@ -1,350 +0,0 @@
# ============================================================================
#
# The configuration produced by default is meant to highlight the features
# that Zensical provides and to serve as a starting point for your own
# projects.
#
# ============================================================================
[project]
# The site_name is shown in the page header and the browser window title
#
# Read more: https://zensical.org/docs/setup/basics/#site_name
site_name = "{{project_name}} Documentation"
# The site_description is included in the HTML head and should contain a
# meaningful description of the site content for use by search engines.
#
# Read more: https://zensical.org/docs/setup/basics/#site_description
site_description = "Documentation site for {{project_name}}"
# The site_author attribute. This is used in the HTML head element.
#
# Read more: https://zensical.org/docs/setup/basics/#site_author
site_author = "John Lancaster"
# The site_url is the canonical URL for your site. When building online
# documentation you should set this.
# Read more: https://zensical.org/docs/setup/basics/#site_url
#site_url = "https://www.example.com/"
# The copyright notice appears in the page footer and can contain an HTML
# fragment.
#
# Read more: https://zensical.org/docs/setup/basics/#copyright
copyright = """
Copyright &copy; 2026 The authors
"""
# Zensical supports both implicit navigation and explicitly defined navigation.
# If you decide not to define a navigation here then Zensical will simply
# derive the navigation structure from the directory structure of your
# "docs_dir". The definition below demonstrates how a navigation structure
# can be defined using TOML syntax.
#
# Read more: https://zensical.org/docs/setup/navigation/
# nav = [
# { "Get started" = "index.md" },
# { "Markdown in 5min" = "markdown.md" },
# ]
# With the "extra_css" option you can add your own CSS styling to customize
# your Zensical project according to your needs. You can add any number of
# CSS files.
#
# The path provided should be relative to the "docs_dir".
#
# Read more: https://zensical.org/docs/customization/#additional-css
#
#extra_css = ["stylesheets/extra.css"]
# With the `extra_javascript` option you can add your own JavaScript to your
# project to customize the behavior according to your needs.
#
# The path provided should be relative to the "docs_dir".
#
# Read more: https://zensical.org/docs/customization/#additional-javascript
#extra_javascript = ["javascripts/extra.js"]
# ----------------------------------------------------------------------------
# Section for configuring theme options
# ----------------------------------------------------------------------------
[project.theme]
# change this to "classic" to use the traditional Material for MkDocs look.
#variant = "classic"
# Zensical allows you to override specific blocks, partials, or whole
# templates as well as to define your own templates. To do this, uncomment
# the custom_dir setting below and set it to a directory in which you
# keep your template overrides.
#
# Read more:
# - https://zensical.org/docs/customization/#extending-the-theme
#
#custom_dir = "overrides"
# With the "favicon" option you can set your own image to use as the icon
# browsers will use in the browser title bar or tab bar. The path provided
# must be relative to the "docs_dir".
#
# Read more:
# - https://zensical.org/docs/setup/logo-and-icons/#favicon
# - https://developer.mozilla.org/en-US/docs/Glossary/Favicon
#
#favicon = "images/favicon.png"
# Zensical supports more than 60 different languages. This means that the
# labels and tooltips that Zensical's templates produce are translated.
# The "language" option allows you to set the language used. This language
# is also indicated in the HTML head element to help with accessibility
# and guide search engines and translation tools.
#
# The default language is "en" (English). It is possible to create
# sites with multiple languages and configure a language selector. See
# the documentation for details.
#
# Read more:
# - https://zensical.org/docs/setup/language/
#
language = "en"
# Zensical provides a number of feature toggles that change the behavior
# of the documentation site.
features = [
# Zensical includes an announcement bar. This feature allows users to
# dismiss it when they have read the announcement.
# https://zensical.org/docs/setup/header/#announcement-bar
"announce.dismiss",
# If you have a repository configured and turn on this feature, Zensical
# will generate an edit button for the page. This works for common
# repository hosting services.
# https://zensical.org/docs/setup/repository/#content-actions
#"content.action.edit",
# If you have a repository configured and turn on this feature, Zensical
# will generate a button that allows the user to view the Markdown
# code for the current page.
# https://zensical.org/docs/setup/repository/#content-actions
#"content.action.view",
# Code annotations allow you to add an icon with a tooltip to your
# code blocks to provide explanations at crucial points.
# https://zensical.org/docs/authoring/code-blocks/#code-annotations
"content.code.annotate",
# This feature turns on a button in code blocks that allow users to
# copy the content to their clipboard without first selecting it.
# https://zensical.org/docs/authoring/code-blocks/#code-copy-button
"content.code.copy",
# Code blocks can include a button to allow for the selection of line
# ranges by the user.
# https://zensical.org/docs/authoring/code-blocks/#code-selection-button
"content.code.select",
# Zensical can render footnotes as inline tooltips, so the user can read
# the footnote without leaving the context of the document.
# https://zensical.org/docs/authoring/footnotes/#footnote-tooltips
"content.footnote.tooltips",
# If you have many content tabs that have the same titles (e.g., "Python",
# "JavaScript", "Cobol"), this feature causes all of them to switch to
# at the same time when the user chooses their language in one.
# https://zensical.org/docs/authoring/content-tabs/#linked-content-tabs
"content.tabs.link",
# With this feature enabled users can add tooltips to links that will be
# displayed when the mouse pointer hovers the link.
# https://zensical.org/docs/authoring/tooltips/#improved-tooltips
"content.tooltips",
# With this feature enabled, Zensical will automatically hide parts
# of the header when the user scrolls past a certain point.
# https://zensical.org/docs/setup/header/#automatic-hiding
# "header.autohide",
# Turn on this feature to expand all collapsible sections in the
# navigation sidebar by default.
# https://zensical.org/docs/setup/navigation/#navigation-expansion
# "navigation.expand",
# This feature turns on navigation elements in the footer that allow the
# user to navigate to a next or previous page.
# https://zensical.org/docs/setup/footer/#navigation
"navigation.footer",
# When section index pages are enabled, documents can be directly attached
# to sections, which is particularly useful for providing overview pages.
# https://zensical.org/docs/setup/navigation/#section-index-pages
"navigation.indexes",
# When instant navigation is enabled, clicks on all internal links will be
# intercepted and dispatched via XHR without fully reloading the page.
# https://zensical.org/docs/setup/navigation/#instant-navigation
"navigation.instant",
# With instant prefetching, your site will start to fetch a page once the
# user hovers over a link. This will reduce the perceived loading time
# for the user.
# https://zensical.org/docs/setup/navigation/#instant-prefetching
"navigation.instant.prefetch",
# In order to provide a better user experience on slow connections when
# using instant navigation, a progress indicator can be enabled.
# https://zensical.org/docs/setup/navigation/#progress-indicator
#"navigation.instant.progress",
# When navigation paths are activated, a breadcrumb navigation is rendered
# above the title of each page
# https://zensical.org/docs/setup/navigation/#navigation-path
"navigation.path",
# When pruning is enabled, only the visible navigation items are included
# in the rendered HTML, reducing the size of the built site by 33% or more.
# https://zensical.org/docs/setup/navigation/#navigation-pruning
#"navigation.prune",
# When sections are enabled, top-level sections are rendered as groups in
# the sidebar for viewports above 1220px, but remain as-is on mobile.
# https://zensical.org/docs/setup/navigation/#navigation-sections
"navigation.sections",
# When tabs are enabled, top-level sections are rendered in a menu layer
# below the header for viewports above 1220px, but remain as-is on mobile.
# https://zensical.org/docs/setup/navigation/#navigation-tabs
#"navigation.tabs",
# When sticky tabs are enabled, navigation tabs will lock below the header
# and always remain visible when scrolling down.
# https://zensical.org/docs/setup/navigation/#sticky-navigation-tabs
#"navigation.tabs.sticky",
# A back-to-top button can be shown when the user, after scrolling down,
# starts to scroll up again.
# https://zensical.org/docs/setup/navigation/#back-to-top-button
"navigation.top",
# When anchor tracking is enabled, the URL in the address bar is
# automatically updated with the active anchor as highlighted in the table
# of contents.
# https://zensical.org/docs/setup/navigation/#anchor-tracking
"navigation.tracking",
# When search highlighting is enabled and a user clicks on a search result,
# Zensical will highlight all occurrences after following the link.
# https://zensical.org/docs/setup/search/#search-highlighting
"search.highlight",
# When anchor following for the table of contents is enabled, the sidebar
# is automatically scrolled so that the active anchor is always visible.
# https://zensical.org/docs/setup/navigation/#anchor-following
# "toc.follow",
# When navigation integration for the table of contents is enabled, it is
# always rendered as part of the navigation sidebar on the left.
# https://zensical.org/docs/setup/navigation/#navigation-integration
#"toc.integrate",
]
# ----------------------------------------------------------------------------
# You can configure your own logo to be shown in the header using the "logo"
# option in the "theme" subsection. The logo must be a relative path to a file
# in your "docs_dir", e.g., to use `docs/assets/logo.png` you would set:
# ----------------------------------------------------------------------------
#logo = "assets/logo.png"
# ----------------------------------------------------------------------------
# If you don't have a dedicated project logo, you can use a built-in icon from
# the icon sets shipped in Zensical. Please note that the setting lives in a
# different subsection, and that the above take precedence over the icon.
#
# Read more:
# - https://zensical.org/docs/setup/logo-and-icons
# - https://github.com/zensical/ui/tree/master/dist/.icons
# ----------------------------------------------------------------------------
#[project.theme.icon]
#logo = "lucide/smile"
# ----------------------------------------------------------------------------
# In the "font" subsection you can configure the fonts used. By default, fonts
# are loaded from Google Fonts, giving you a wide range of choices from a set
# of suitably licensed fonts. There are options for a normal text font and for
# a monospaced font used in code blocks.
# ----------------------------------------------------------------------------
#[project.theme.font]
#text = "Inter"
#code = "Jetbrains Mono"
# ----------------------------------------------------------------------------
# In the "palette" subsection you can configure options for the color scheme.
# You can configure different color schemes, e.g., to turn on dark mode,
# that the user can switch between. Each color scheme can be further
# customized.
#
# Read more:
# - https://zensical.org/docs/setup/colors/
# ----------------------------------------------------------------------------
[[project.theme.palette]]
scheme = "default"
toggle.icon = "lucide/sun"
toggle.name = "Switch to dark mode"
[[project.theme.palette]]
scheme = "slate"
toggle.icon = "lucide/moon"
toggle.name = "Switch to light mode"
# ----------------------------------------------------------------------------
# The "extra" section contains miscellaneous settings.
# ----------------------------------------------------------------------------
#[[project.extra.social]]
#icon = "fontawesome/brands/github"
#link = "https://github.com/user/repo"
# ----------------------------------------------------------------------------
# In this section you can configure the Markdown extensions that are used when
# rendering your documentation. We enable the most useful extensions by default,
# but you can customize this list to your needs.
#
# Read more:
# - https://zensical.org/docs/setup/extensions/
# ----------------------------------------------------------------------------
[project.markdown_extensions.abbr]
[project.markdown_extensions.admonition]
[project.markdown_extensions.attr_list]
[project.markdown_extensions.def_list]
[project.markdown_extensions.footnotes]
[project.markdown_extensions.md_in_html]
[project.markdown_extensions.toc]
permalink = true
[project.markdown_extensions.pymdownx.arithmatex]
generic = true
[project.markdown_extensions.pymdownx.betterem]
[project.markdown_extensions.pymdownx.caret]
[project.markdown_extensions.pymdownx.details]
[project.markdown_extensions.pymdownx.emoji]
emoji_generator = "zensical.extensions.emoji.to_svg"
emoji_index = "zensical.extensions.emoji.twemoji"
[project.markdown_extensions.pymdownx.highlight]
anchor_linenums = true
line_spans = "__span"
pygments_lang_class = true
[project.markdown_extensions.pymdownx.inlinehilite]
[project.markdown_extensions.pymdownx.keys]
[project.markdown_extensions.pymdownx.magiclink]
[project.markdown_extensions.pymdownx.mark]
[project.markdown_extensions.pymdownx.smartsymbols]
[project.markdown_extensions.pymdownx.snippets]
[project.markdown_extensions.pymdownx.superfences]
custom_fences = [
{ name = "mermaid", class = "mermaid", format = "pymdownx.superfences.fence_code_format" }
]
[project.markdown_extensions.pymdownx.tabbed]
alternate_style = true
combine_header_slug = true
[project.markdown_extensions.pymdownx.tasklist]
custom_checkbox = true
[project.markdown_extensions.pymdownx.tilde]
@@ -1,2 +0,0 @@
# Changes here will be overwritten by Copier
{{ _copier_answers|to_nice_yaml -}}
+13
View File
@@ -0,0 +1,13 @@
# Prompt Artifacts
This directory stores transcription prompts as individual Markdown artifacts.
## Conventions
- Keep one prompt per file.
- Use stable, descriptive snake_case file names.
- Prefer incremental edits to a single prompt per change for clean history.
- Keep prompts human-readable and policy-focused.
- Do not store secrets in prompt files.
## Current Prompt
- `transcribe_document.md`: baseline verbatim transcription policy for historical documents.
+10
View File
@@ -0,0 +1,10 @@
You are an assistant that may call tools.
Tool safety rules:
1) Tool arguments MUST be strict JSON matching the schema exactly.
2) Never place disallowed, sensitive, explicit, or policy-violating text directly into tool arguments.
3) If user content may be unsafe, first produce a brief neutral summary and pass only that summary.
4) Prefer IDs, enums, booleans, and short fields over raw free-form text.
5) Keep all string arguments <= 300 chars unless schema says otherwise.
6) If you cannot safely provide valid tool args, do not call the tool; respond with "NO_TOOL_CALL" and explain briefly.
7) Never include markdown/code fences in tool arguments.
+72
View File
@@ -0,0 +1,72 @@
# Historical Document Verbatim Transcription Prompt
## Purpose
Transcribe the provided historical document image as a faithful **verbatim** transcript.
Do not summarize. Do not paraphrase. Do not modernize style.
## Output Contract
- Return only the transcription text.
- Preserve original wording, punctuation, and meaningful structure.
- Keep line/section flow readable while preserving intent and document organization.
- Never invent missing content.
## Rules for Ambiguous or Damaged Text
### Misspellings and original errors
- Preserve original spelling.
- Add `[sic]` immediately after an evident original error.
### Missing words or clear omissions
- If a single missing word is obvious from context, insert it in square brackets.
- Example form: `[to]`
### Uncertain readings
- If best-effort interpretation is uncertain, use bracketed guess with question mark.
- Example form: `[Boston?]`
### Completely illegible text
- Use a clear bracketed label.
- Preferred forms: `[illegible]`, `[torn]`, `[ink blot]`, `[remainder of page torn]`
### Crossed-out text
- Preserve it using: `[deleted: ...]`
### Squeezed-in or above-line insertions
- Preserve it using: `[inserted: ...]`
### Superscripts and abbreviations
- Bring superscript letters down to baseline text.
- Expand only when clearly intended; if expanded, place added letters in brackets.
### Non-text visual elements
- Describe briefly in square brackets.
- Example forms: `[wax notary seal attached here]`, `[sketch of a fort layout]`
### Marginalia and side notes
- Signal location before the note text.
- Example form: `[written in left margin: ...]`
### Line-break hyphenation
- Rejoin words split across line breaks when they are clearly one word.
- Remove only line-break hyphens used for wrapping.
### Ambiguous capitalization
- Prefer modern capitalization only when uncertainty is high.
- Preserve clearly intentional archaic capitalization.
### Hierarchical outlines and numbering
- Preserve original numbering characters exactly (including roman numerals and unusual suffixes).
- Preserve indentation levels.
- Do not silently correct sequence mistakes; if clearly erroneous, preserve and use `[sic]` where appropriate.
## Confidence and Integrity Policy
- When uncertain, mark uncertainty explicitly rather than guessing silently.
- If text cannot be read, use a bracketed illegibility label instead of fabrication.
- Do not add commentary outside the transcription.
## Final Self-Check
Before finalizing, ensure:
1. The transcript is verbatim and not summarized.
2. Uncertain/illegible areas are explicitly marked.
3. Crossed-out and inserted text are preserved with required tags.
4. Structure/ordering is preserved as faithfully as possible.
+30 -2
View File
@@ -1,7 +1,35 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/transcription"]
[project] [project]
name = "python-template" name = "transcription"
version = "0.1.0" version = "0.1.0"
description = "Historical document transcription system"
readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"copier>=9.15.1", "fastapi>=0.138.0",
"nicegui==3.13.0",
"openrouter>=0.7.0",
"pydantic>=2.13.4",
"pydantic-settings>=2.9.1",
"sqlmodel>=0.0.25",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.25",
]
[tool.pytest.ini_options]
addopts = "--strict-markers -q"
markers = [
"unit: pure logic tests with no external dependencies",
"integration: tests that touch framework or database contracts",
"external: tests that call external services (slow, requires credentials)",
] ]
+1
View File
@@ -0,0 +1 @@
"""API route modules for the transcription app."""
+62
View File
@@ -0,0 +1,62 @@
"""Centralized API exception handlers."""
from __future__ import annotations
import logging
from fastapi import FastAPI
from fastapi import Request
from fastapi.responses import JSONResponse
from transcription.errors import AppError
from transcription.errors import ErrorCategory
from transcription.errors import build_error_envelope
logger = logging.getLogger(__name__)
_STATUS_BY_CATEGORY: dict[ErrorCategory, int] = {
ErrorCategory.VALIDATION: 400,
ErrorCategory.USER_INPUT: 400,
ErrorCategory.NOT_FOUND: 404,
ErrorCategory.CONFLICT: 409,
ErrorCategory.EXTERNAL_PROVIDER: 503,
ErrorCategory.INFRA_TRANSIENT: 503,
ErrorCategory.INFRA_PERSISTENT: 500,
ErrorCategory.INTERNAL_UNEXPECTED: 500,
}
def _status_for(error: AppError) -> int:
return _STATUS_BY_CATEGORY.get(error.category, 500)
def register_error_handlers(app: FastAPI) -> None:
"""Register API exception handlers on the app."""
@app.exception_handler(AccessDeniedError)
async def access_denied_handler(_request: Request, exc: AccessDeniedError) -> JSONResponse:
envelope = build_error_envelope(exc)
headers = {"WWW-Authenticate": "Basic"} if exc.should_challenge else None
return JSONResponse(status_code=401, content=envelope.__dict__, headers=headers)
@app.exception_handler(AppError)
async def app_error_handler(_request: Request, exc: AppError) -> JSONResponse:
envelope = build_error_envelope(exc)
return JSONResponse(status_code=_status_for(exc), content=envelope.__dict__)
@app.exception_handler(Exception)
async def fallback_error_handler(_request: Request, exc: Exception) -> JSONResponse:
normalized = AppError(
"Unexpected error while handling request",
category=ErrorCategory.INTERNAL_UNEXPECTED,
suggestion="Retry once. If it persists, report the error reference id.",
)
logger.exception(
"Unhandled API exception operation=api.request error_id=%s category=%s exception_type=%s",
normalized.error_id,
normalized.category.value,
type(exc).__name__,
)
envelope = build_error_envelope(normalized)
return JSONResponse(status_code=_status_for(normalized), content=envelope.__dict__)
+16
View File
@@ -0,0 +1,16 @@
"""Health endpoint routes."""
from fastapi import APIRouter
router = APIRouter()
def healthz() -> dict[str, str]:
"""Return a simple health status payload."""
return {"status": "ok"}
@router.get("/healthz")
def healthz_route() -> dict[str, str]:
"""Route wrapper for health status payload."""
return healthz()
+161
View File
@@ -0,0 +1,161 @@
"""Functional API routes for jobs, revisions, search, and export."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter
from pydantic import BaseModel
from pydantic import Field
from transcription.services.library import accept_revision
from transcription.services.library import add_revision
from transcription.services.library import export_transcripts
from transcription.services.library import get_job_detail
from transcription.services.library import list_jobs
from transcription.services.library import list_revisions
from transcription.services.library import search_accepted_transcripts
router = APIRouter(prefix="/api", tags=["transcription"])
class CreateRevisionRequest(BaseModel):
text: str = Field(min_length=1)
source: str = "user"
accepted: bool = False
@router.get("/jobs")
def get_jobs() -> list[dict[str, str]]:
jobs = list_jobs()
return [
{
"id": str(job.id),
"document_id": str(job.document_id),
"status": job.status.value,
"created_at": job.created_at.isoformat(),
"updated_at": job.updated_at.isoformat(),
}
for job in jobs
]
@router.get("/jobs/{job_id}")
def get_job(job_id: UUID) -> dict[str, object | None]:
detail = get_job_detail(job_id=job_id)
return {
"job": {
"id": str(detail.job.id),
"document_id": str(detail.job.document_id),
"status": detail.job.status.value,
"created_at": detail.job.created_at.isoformat(),
"updated_at": detail.job.updated_at.isoformat(),
},
"document": (
{
"id": str(detail.document.id),
"filename": detail.document.filename,
"file_path": detail.document.file_path,
}
if detail.document is not None
else None
),
"transcript": (
{
"id": str(detail.transcript.id),
"text": detail.transcript.text,
"error_detail": detail.transcript.error_detail,
"created_at": detail.transcript.created_at.isoformat(),
}
if detail.transcript is not None
else None
),
"accepted_revision": (
{
"id": str(detail.accepted_revision.id),
"revision_number": detail.accepted_revision.revision_number,
"text": detail.accepted_revision.text,
"source": detail.accepted_revision.source,
"created_at": detail.accepted_revision.created_at.isoformat(),
}
if detail.accepted_revision is not None
else None
),
}
@router.get("/jobs/{job_id}/revisions")
def get_job_revisions(job_id: UUID) -> list[dict[str, object]]:
revisions = list_revisions(job_id=job_id)
return [
{
"id": str(revision.id),
"job_id": str(revision.job_id),
"revision_number": revision.revision_number,
"text": revision.text,
"source": revision.source,
"accepted": revision.accepted,
"created_at": revision.created_at.isoformat(),
}
for revision in revisions
]
@router.post("/jobs/{job_id}/revisions")
def create_job_revision(job_id: UUID, payload: CreateRevisionRequest) -> dict[str, object]:
revision = add_revision(
job_id=job_id,
text=payload.text,
source=payload.source,
accepted=payload.accepted,
)
return {
"id": str(revision.id),
"job_id": str(revision.job_id),
"revision_number": revision.revision_number,
"text": revision.text,
"source": revision.source,
"accepted": revision.accepted,
"created_at": revision.created_at.isoformat(),
}
@router.post("/revisions/{revision_id}/accept")
def accept_job_revision(revision_id: UUID) -> dict[str, object]:
revision = accept_revision(revision_id=revision_id)
return {
"id": str(revision.id),
"job_id": str(revision.job_id),
"revision_number": revision.revision_number,
"text": revision.text,
"source": revision.source,
"accepted": revision.accepted,
"created_at": revision.created_at.isoformat(),
}
@router.get("/search")
def search(query: str) -> list[dict[str, object]]:
results = search_accepted_transcripts(query=query)
return [
{
"revision_id": str(revision.id),
"job_id": str(revision.job_id),
"revision_number": revision.revision_number,
"text": revision.text,
"source": revision.source,
"accepted": revision.accepted,
"created_at": revision.created_at.isoformat(),
}
for revision in results
]
@router.get("/export")
def export(accepted_only: bool = True) -> dict[str, object]:
records = export_transcripts(accepted_only=accepted_only)
return {
"count": len(records),
"accepted_only": accepted_only,
"records": records,
}
+95
View File
@@ -0,0 +1,95 @@
"""Application factory and lifespan wiring for the transcription app."""
from __future__ import annotations
from contextlib import asynccontextmanager
from threading import Event
from threading import Thread
from fastapi import FastAPI
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlmodel.ext.asyncio.session import AsyncSession
from .api.errors import register_error_handlers
from .api.health import router as health_router
from .config import configure_logging
from .config import get_settings
from .db import cleanup_database
from .db import create_all
from .db import initialize_database_runtime
from .ui import register_pages
from .worker import run_worker_loop
def _start_worker(app: FastAPI) -> None:
session_factory: async_sessionmaker[AsyncSession] = app.state.db_session_factory
stop_event = Event()
worker_thread = Thread(
target=run_worker_loop,
kwargs={
"session_factory": session_factory,
"stop_event": stop_event,
"poll_interval_seconds": 1.0,
},
daemon=True,
)
worker_thread.start()
app.state.worker_stop_event = stop_event
app.state.worker_thread = worker_thread
def _stop_worker(app: FastAPI) -> None:
stop_event = getattr(app.state, "worker_stop_event", None)
worker_thread = getattr(app.state, "worker_thread", None)
if stop_event is not None:
stop_event.set()
if worker_thread is not None:
worker_thread.join(timeout=2.0)
@asynccontextmanager
async def _lifespan(app: FastAPI):
configure_logging()
settings = get_settings()
app.state.settings = settings
runtime = initialize_database_runtime(settings=settings)
app.state.db_engine = runtime.engine
app.state.db_session_factory = runtime.session_factory
if settings.should_bootstrap_schema:
await create_all(engine=runtime.engine)
settings.upload_dir.mkdir(parents=True, exist_ok=True)
settings.prompt_dir.mkdir(parents=True, exist_ok=True)
_start_worker(app)
try:
yield
finally:
_stop_worker(app)
await cleanup_database()
def create_app() -> FastAPI:
"""Create and configure the FastAPI application."""
app = FastAPI(title="Transcription", lifespan=_lifespan)
@app.middleware("http")
async def operator_access_middleware(request: Request, call_next):
settings = get_settings()
try:
enforce_request_access(request=request, settings=settings)
except AccessDeniedError as exc:
envelope = build_error_envelope(exc)
headers = {"WWW-Authenticate": "Basic"} if exc.should_challenge else None
return JSONResponse(status_code=401, content=envelope.__dict__, headers=headers)
return await call_next(request)
register_error_handlers(app)
register_pages(app)
app.include_router(health_router)
app.include_router(transcription_router)
return app
+115
View File
@@ -0,0 +1,115 @@
"""Centralized application configuration.
All settings are loaded from environment variables (or a .env file)
once at startup. Provider-specific defaults (model names, base URLs)
are resolved by the provider adapters, not here.
"""
import logging.config
from contextvars import ContextVar
from enum import StrEnum
from pathlib import Path
from typing import Literal
from pydantic_settings import BaseSettings
from pydantic_settings import SettingsConfigDict
logger = logging.getLogger(__name__)
class Provider(StrEnum):
OPENROUTER = "openrouter"
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
# --- AI provider ---
provider: Provider = Provider.OPENROUTER
openrouter_api_key: str
provider_model: str | None = None
openrouter_http_referer: str | None = None
openrouter_app_title: str | None = None
# --- runtime environment ---
environment: Literal["development", "test", "production"] = "development"
# --- persistence ---
database_url: str = "sqlite:///./transcription.db"
bootstrap_schema_on_startup: bool | None = None
migration_auto_apply_on_startup: bool = False
validate_schema_on_startup: bool = True
# --- filesystem paths ---
upload_dir: Path = Path("./uploads")
prompt_dir: Path = Path("./prompts")
# --- upload safety ---
max_upload_bytes: int = 15 * 1024 * 1024
# --- single-operator access control ---
operator_access_enabled: bool = False
operator_username: str = "operator"
operator_password: str | None = None
# --- worker reliability ---
worker_max_retries: int = 0
worker_retry_backoff_seconds: float = 0.0
@property
def should_bootstrap_schema(self) -> bool:
"""Return whether startup should auto-create schema for this environment."""
if self.bootstrap_schema_on_startup is not None:
return self.bootstrap_schema_on_startup
return self.environment in {"development", "test"}
_settings: ContextVar[Settings | None] = ContextVar("settings", default=None)
def get_settings() -> Settings:
settings = _settings.get()
if settings is None:
settings = Settings() # pyright: ignore[reportCallIssue]
_settings.set(settings)
return settings
LOGGING_CONFIG: dict[str, object] = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"standard": {
"format": "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
"datefmt": "%Y-%m-%d %H:%M:%S",
}
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "standard",
"stream": "ext://sys.stdout",
}
},
"root": {
"level": "INFO",
"handlers": ["console"],
},
"loggers": {
"transcription": {
"level": "DEBUG",
"handlers": ["console"],
"propagate": False,
}
},
}
def configure_logging() -> None:
"""Configure root logging once at startup."""
logging.config.dictConfig(LOGGING_CONFIG)
logger.debug("Logging configured")
+149
View File
@@ -0,0 +1,149 @@
"""Database runtime ownership, schema bootstrap, and session access.
V1 moves database resource ownership to explicit runtime initialization so
startup/shutdown behavior is predictable and lifespan-managed.
"""
from __future__ import annotations
import contextlib
import logging
from collections.abc import AsyncGenerator
from dataclasses import dataclass
from sqlalchemy import inspect
from sqlalchemy import text
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import AsyncEngine
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlalchemy.ext.asyncio import create_async_engine
from sqlmodel import SQLModel
from sqlmodel.ext.asyncio.session import AsyncSession
from .config import Settings
from .config import get_settings
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class DatabaseRuntime:
"""Database runtime resources owned by app lifespan."""
engine: AsyncEngine
session_factory: async_sessionmaker[AsyncSession]
_runtime: DatabaseRuntime | None = None
def _to_async_database_url(database_url: str) -> str:
"""Normalize configured database URL to an async SQLAlchemy driver URL."""
if database_url.startswith("sqlite://") and not database_url.startswith("sqlite+aiosqlite://"):
return database_url.replace("sqlite://", "sqlite+aiosqlite://", 1)
if database_url.startswith("postgresql://") and not database_url.startswith("postgresql+asyncpg://"):
return database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
return database_url
def _build_engine(settings: Settings) -> AsyncEngine:
database_url = _to_async_database_url(settings.database_url)
connect_args: dict[str, object] = {}
if database_url.startswith("sqlite"):
connect_args["check_same_thread"] = False
return create_async_engine(
url=database_url,
echo=False,
pool_pre_ping=True,
connect_args=connect_args,
)
def initialize_database_runtime(*, settings: Settings | None = None) -> DatabaseRuntime:
"""Initialize lifespan-owned async DB resources once per process."""
global _runtime
if _runtime is not None:
return _runtime
active_settings = settings or get_settings()
engine = _build_engine(active_settings)
session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
_runtime = DatabaseRuntime(engine=engine, session_factory=session_factory)
logger.debug("Initialized async database runtime for database_url=%s", engine.url)
return _runtime
def get_engine() -> AsyncEngine:
"""Return the current async SQLAlchemy engine."""
runtime = _runtime or initialize_database_runtime()
return runtime.engine
def get_session_factory() -> async_sessionmaker[AsyncSession]:
"""Return the shared async session factory."""
runtime = _runtime or initialize_database_runtime()
return runtime.session_factory
async def cleanup_database() -> None:
"""Cleanup database runtime resources."""
await dispose_database_runtime()
async def dispose_database_runtime() -> None:
"""Dispose lifespan-owned async database resources."""
global _runtime
if _runtime is None:
return
await _runtime.engine.dispose()
_runtime = None
async def create_all(*, engine: AsyncEngine | None = None) -> None:
"""Create all tables on the selected engine."""
# Import models so SQLModel metadata is fully registered before bootstrap.
from transcription import models as _models # noqa: F401
active_engine = engine or get_engine()
async with active_engine.begin() as connection:
await connection.run_sync(SQLModel.metadata.create_all)
await connection.run_sync(_ensure_sqlite_compat_columns)
logger.debug("Database schema bootstrap complete for database_url=%s", active_engine.url)
def _ensure_sqlite_compat_columns(connection: Connection) -> None:
"""Apply lightweight dev/test SQLite compatibility column patches.
This performs read-only validation and never mutates schema.
"""
if connection.engine.url.get_backend_name() != "sqlite":
return
inspector = inspect(connection)
table_names = set(inspector.get_table_names())
required_tables = {"document", "job", "transcript", "transcriptrevision"}
missing_tables = sorted(required_tables - table_names)
for table_name in missing_tables:
issues.append(f"missing_table:{table_name}")
columns = {column["name"] for column in inspector.get_columns("job")}
if "retry_count" not in columns:
connection.execute(text("ALTER TABLE job ADD COLUMN retry_count INTEGER NOT NULL DEFAULT 0"))
logger.warning("Applied SQLite compatibility schema patch table=job column=retry_count default=0")
@contextlib.asynccontextmanager
async def get_session(
*,
session_factory: async_sessionmaker[AsyncSession] | None = None,
) -> AsyncGenerator[AsyncSession]:
"""Yield a database session and ensure cleanup."""
active_session_factory = session_factory or get_session_factory()
async with active_session_factory() as session:
yield session
def should_bootstrap_schema(settings: Settings) -> bool:
"""Compatibility helper for explicit bootstrap checks."""
return settings.should_bootstrap_schema
+88
View File
@@ -0,0 +1,88 @@
"""Shared error taxonomy and helpers for runtime boundaries."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC
from datetime import datetime
from enum import StrEnum
from uuid import uuid4
class ErrorCategory(StrEnum):
"""Stable error categories defined by docs/error_handling.md."""
VALIDATION = "validation_error"
USER_INPUT = "user_input_error"
NOT_FOUND = "not_found_error"
CONFLICT = "conflict_error"
EXTERNAL_PROVIDER = "external_provider_error"
INFRA_TRANSIENT = "infrastructure_transient_error"
INFRA_PERSISTENT = "infrastructure_persistent_error"
INTERNAL_UNEXPECTED = "internal_unexpected_error"
def new_error_id() -> str:
"""Return a short, user-shareable error reference id."""
return uuid4().hex[:8]
class AppError(RuntimeError):
"""Base application error carrying user-safe handling metadata."""
def __init__(
self,
message: str,
*,
category: ErrorCategory = ErrorCategory.INTERNAL_UNEXPECTED,
suggestion: str = "Retry once. If it persists, review logs and report the error reference id.",
retriable: bool = False,
error_id: str | None = None,
) -> None:
super().__init__(message)
self.message = message
self.category = category
self.suggestion = suggestion
self.retriable = retriable
self.error_id = error_id or new_error_id()
@dataclass(frozen=True)
class ErrorEnvelope:
"""Serializable API/UI error payload."""
error_id: str
category: str
message: str
suggestion: str
timestamp: str
def build_error_envelope(error: AppError) -> ErrorEnvelope:
"""Build an API-safe response envelope from an AppError."""
return ErrorEnvelope(
error_id=error.error_id,
category=error.category.value,
message=error.message,
suggestion=error.suggestion,
timestamp=datetime.now(UTC).isoformat(),
)
def classify_unexpected_error(exc: Exception, *, operation: str) -> AppError:
"""Normalize unknown exceptions into internal_unexpected_error."""
_ = exc
return AppError(
f"Unexpected error during {operation}",
category=ErrorCategory.INTERNAL_UNEXPECTED,
suggestion="Retry once. If it persists, review logs and report the error reference id.",
retriable=False,
)
def format_error_detail(error: AppError) -> str:
"""Return a compact persisted failure string for transcript.error_detail."""
return (
f"[{error.category.value}] {error.message} | "
f"suggestion={error.suggestion} | error_id={error.error_id}"
)
+75
View File
@@ -0,0 +1,75 @@
"""CLI entrypoint for explicit schema migration and compatibility checks."""
from __future__ import annotations
import argparse
from transcription.config import get_settings
from transcription.db import initialize_database_runtime
from transcription.db import validate_schema_compatibility
from transcription.migrations import apply_pending_migrations
from transcription.migrations import list_pending_migrations
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Transcription schema migration runner")
parser.add_argument(
"--apply",
action="store_true",
help="Apply all pending migrations.",
)
parser.add_argument(
"--list",
action="store_true",
help="List pending migrations.",
)
parser.add_argument(
"--check",
action="store_true",
help="Run schema compatibility check.",
)
return parser
def main() -> int:
parser = _build_parser()
args = parser.parse_args()
if not (args.apply or args.list or args.check):
parser.error("Specify at least one action: --list, --apply, or --check")
runtime = initialize_database_runtime(settings=get_settings())
engine = runtime.engine
if args.list:
pending = list_pending_migrations(engine=engine)
if not pending:
print("No pending migrations.")
else:
print("Pending migrations:")
for migration in pending:
print(f"- {migration.revision_id}: {migration.description}")
if args.apply:
applied = apply_pending_migrations(engine=engine)
if not applied:
print("No migrations applied.")
else:
print("Applied migrations:")
for revision_id in applied:
print(f"- {revision_id}")
if args.check:
issues = validate_schema_compatibility(engine=engine)
if issues:
print("Schema compatibility check failed:")
for issue in issues:
print(f"- {issue}")
return 1
print("Schema compatibility check passed.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+132
View File
@@ -0,0 +1,132 @@
"""Lightweight schema migration helpers for V1 Step 4.
This module provides explicit, operator-invoked migration execution for
personal-scale deployments without introducing heavyweight migration tooling.
"""
from __future__ import annotations
import logging
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC
from datetime import datetime
from sqlalchemy import inspect
from sqlalchemy import text
from sqlalchemy.engine import Connection
from sqlalchemy.engine import Engine
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class MigrationRevision:
"""Represents one ordered schema migration revision."""
revision_id: str
description: str
apply: Callable[[Connection], None]
def _ensure_history_table(connection: Connection) -> None:
"""Create migration history table when missing."""
connection.execute(
text(
"""
CREATE TABLE IF NOT EXISTS schema_migration_history (
revision_id VARCHAR(64) PRIMARY KEY,
description VARCHAR(255) NOT NULL,
applied_at VARCHAR(64) NOT NULL
)
"""
)
)
def _get_applied_revisions(connection: Connection) -> set[str]:
"""Return applied migration revision IDs."""
_ensure_history_table(connection)
rows = connection.execute(text("SELECT revision_id FROM schema_migration_history")).fetchall()
return {row[0] for row in rows}
def _record_revision(connection: Connection, revision: MigrationRevision) -> None:
"""Persist one applied migration revision record."""
connection.execute(
text(
"""
INSERT INTO schema_migration_history (revision_id, description, applied_at)
VALUES (:revision_id, :description, :applied_at)
"""
),
{
"revision_id": revision.revision_id,
"description": revision.description,
"applied_at": datetime.now(UTC).isoformat(),
},
)
def _apply_0001_add_retry_count(connection: Connection) -> None:
"""Ensure job.retry_count exists for legacy databases."""
inspector = inspect(connection)
table_names = set(inspector.get_table_names())
if "job" not in table_names:
return
columns = {column["name"] for column in inspector.get_columns("job")}
if "retry_count" in columns:
return
# Compatible with SQLite and PostgreSQL for this additive integer column.
connection.execute(text("ALTER TABLE job ADD COLUMN retry_count INTEGER NOT NULL DEFAULT 0"))
def _apply_0002_create_transcriptrevision(connection: Connection) -> None:
"""Ensure transcriptrevision table exists."""
# Import models lazily so metadata is fully populated.
from sqlmodel import SQLModel
from transcription.models import TranscriptRevision # noqa: F401
table = SQLModel.metadata.tables["transcriptrevision"]
table.create(bind=connection, checkfirst=True)
MIGRATIONS: tuple[MigrationRevision, ...] = (
MigrationRevision(
revision_id="0001_add_retry_count_to_job",
description="Add retry_count column to job table with default 0",
apply=_apply_0001_add_retry_count,
),
MigrationRevision(
revision_id="0002_create_transcriptrevision_table",
description="Create transcriptrevision table for immutable transcript history",
apply=_apply_0002_create_transcriptrevision,
),
)
def list_pending_migrations(*, engine: Engine) -> list[MigrationRevision]:
"""Return pending migrations ordered by revision."""
with engine.begin() as connection:
applied = _get_applied_revisions(connection)
return [revision for revision in MIGRATIONS if revision.revision_id not in applied]
def apply_pending_migrations(*, engine: Engine) -> list[str]:
"""Apply all pending migrations and return applied revision IDs."""
pending = list_pending_migrations(engine=engine)
applied_ids: list[str] = []
for revision in pending:
logger.info("Applying migration revision=%s", revision.revision_id)
with engine.begin() as connection:
_ensure_history_table(connection)
revision.apply(connection)
_record_revision(connection, revision)
applied_ids.append(revision.revision_id)
logger.info("Applied migration revision=%s", revision.revision_id)
return applied_ids
+76
View File
@@ -0,0 +1,76 @@
"""SQLModel domain models for the transcription system."""
from datetime import UTC
from datetime import datetime
from enum import StrEnum
from typing import Optional
from uuid import UUID
from uuid import uuid4
from sqlmodel import Field
from sqlmodel import Relationship
from sqlmodel import SQLModel
class JobStatus(StrEnum):
QUEUED = "queued"
PROCESSING = "processing"
TRANSCRIBED = "transcribed"
COMPLETED = "completed"
FAILED = "failed"
class Document(SQLModel, table=True):
"""An uploaded document image."""
id: UUID = Field(default_factory=uuid4, primary_key=True)
filename: str
file_path: str
uploaded_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
# --- relationships ---
jobs: list["Job"] = Relationship(back_populates="document")
class Job(SQLModel, table=True):
"""A transcription job tied to a single document."""
id: UUID = Field(default_factory=uuid4, primary_key=True)
document_id: UUID = Field(foreign_key="document.id")
status: JobStatus = Field(default=JobStatus.QUEUED)
retry_count: int = Field(default=0, ge=0)
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
# --- relationships ---
document: Document = Relationship(back_populates="jobs")
transcript: Optional["Transcript"] = Relationship(back_populates="job")
revisions: list["TranscriptRevision"] = Relationship(back_populates="job")
class Transcript(SQLModel, table=True):
"""Canonical transcript state for a job (latest text or failure detail)."""
id: UUID = Field(default_factory=uuid4, primary_key=True)
job_id: UUID = Field(foreign_key="job.id", unique=True)
text: str | None = None
error_detail: str | None = None
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
# --- relationships ---
job: Job = Relationship(back_populates="transcript")
class TranscriptRevision(SQLModel, table=True):
"""Immutable transcript revision history for review/acceptance workflows."""
id: UUID = Field(default_factory=uuid4, primary_key=True)
job_id: UUID = Field(foreign_key="job.id", index=True)
revision_number: int = Field(ge=1)
text: str
source: str = Field(default="worker")
accepted: bool = Field(default=False)
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
# --- relationships ---
job: Job = Relationship(back_populates="revisions")
+31
View File
@@ -0,0 +1,31 @@
"""Provider exports and factory for transcription adapters."""
from transcription.config import Provider
from transcription.config import Settings
from transcription.config import get_settings
from transcription.providers.base import ProviderAuthError
from transcription.providers.base import ProviderError
from transcription.providers.base import ProviderResponseError
from transcription.providers.base import TranscriptionProvider
from transcription.providers.base import TranscriptionResult
from transcription.providers.openrouter import OpenRouterTranscriptionProvider
def get_transcription_provider(*, settings: Settings | None = None) -> TranscriptionProvider:
"""Return the configured transcription provider adapter."""
runtime_settings = settings or get_settings()
if runtime_settings.provider == Provider.OPENROUTER:
return OpenRouterTranscriptionProvider(settings=runtime_settings)
raise ProviderError(f"Unsupported transcription provider: {runtime_settings.provider}")
__all__ = [
"OpenRouterTranscriptionProvider",
"ProviderAuthError",
"ProviderError",
"ProviderResponseError",
"TranscriptionProvider",
"TranscriptionResult",
"get_transcription_provider",
]
+32
View File
@@ -0,0 +1,32 @@
"""Provider interfaces and shared types for transcription adapters."""
from dataclasses import dataclass
from typing import Protocol
class ProviderError(RuntimeError):
"""Base error for provider failures."""
class ProviderAuthError(ProviderError):
"""Raised when provider authentication fails."""
class ProviderResponseError(ProviderError):
"""Raised when provider responses are malformed or unusable."""
@dataclass(frozen=True)
class TranscriptionResult:
"""Normalized output returned by any transcription provider."""
text: str
provider: str
model: str
class TranscriptionProvider(Protocol):
"""Contract every transcription provider adapter must satisfy."""
def transcribe(self, *, prompt_text: str, image_bytes: bytes, mime_type: str) -> TranscriptionResult:
"""Transcribe the provided image according to the prompt text."""
+127
View File
@@ -0,0 +1,127 @@
"""OpenRouter transcription provider adapter."""
from __future__ import annotations
import base64
import logging
from dataclasses import dataclass
from typing import Any
from openrouter import OpenRouter
from transcription.config import Settings
from transcription.config import get_settings
from transcription.providers.base import ProviderAuthError
from transcription.providers.base import ProviderError
from transcription.providers.base import ProviderResponseError
from transcription.providers.base import TranscriptionResult
logger = logging.getLogger(__name__)
DEFAULT_OPENROUTER_MODEL = "google/gemini-2.5-flash"
@dataclass(frozen=True)
class OpenRouterRequest:
"""Normalized request payload fields for OpenRouter calls."""
model: str
messages: list[dict[str, Any]]
http_referer: str | None
x_open_router_title: str | None
class OpenRouterTranscriptionProvider:
"""Adapter that performs image transcription through OpenRouter."""
def __init__(self, *, settings: Settings | None = None, client: OpenRouter | None = None):
self._settings = settings or get_settings()
self._model = self._settings.provider_model or DEFAULT_OPENROUTER_MODEL
self._client = client or OpenRouter(api_key=self._settings.openrouter_api_key)
@property
def model(self) -> str:
"""Return the resolved OpenRouter model slug."""
return self._model
def transcribe(self, *, prompt_text: str, image_bytes: bytes, mime_type: str) -> TranscriptionResult:
"""Send prompt + image to OpenRouter and return normalized text output."""
request = self._build_request(prompt_text=prompt_text, image_bytes=image_bytes, mime_type=mime_type)
try:
response = self._client.chat.send(
messages=request.messages,
model=request.model,
http_referer=request.http_referer,
x_open_router_title=request.x_open_router_title,
)
except Exception as exc:
message = str(exc).lower()
if "401" in message or "auth" in message or "api key" in message:
raise ProviderAuthError("OpenRouter authentication failed") from exc
raise ProviderError("OpenRouter request failed") from exc
text = self._extract_text(response)
model = self._get_optional_attr(response, "model") or self.model
logger.info("OpenRouter transcription completed using model=%s", model)
return TranscriptionResult(text=text, provider="openrouter", model=model)
def _build_request(self, *, prompt_text: str, image_bytes: bytes, mime_type: str) -> OpenRouterRequest:
image_b64 = base64.b64encode(image_bytes).decode("ascii")
data_url = f"data:{mime_type};base64,{image_b64}"
messages: list[dict[str, Any]] = [
{
"role": "user",
"content": [
{"type": "text", "text": prompt_text},
{"type": "image_url", "image_url": {"url": data_url}},
],
}
]
return OpenRouterRequest(
model=self.model,
messages=messages,
http_referer=self._settings.openrouter_http_referer,
x_open_router_title=self._settings.openrouter_app_title,
)
def _extract_text(self, response: Any) -> str:
choices = self._get_optional_attr(response, "choices")
if not choices:
raise ProviderResponseError("OpenRouter response missing choices")
first_choice = choices[0]
message = self._get_optional_attr(first_choice, "message")
if message is None:
raise ProviderResponseError("OpenRouter response missing assistant message")
content = self._get_optional_attr(message, "content")
text = self._normalize_content(content)
if not text:
raise ProviderResponseError("OpenRouter response contained no transcription text")
return text
def _normalize_content(self, content: Any) -> str:
if isinstance(content, str):
return content.strip()
if isinstance(content, list):
parts: list[str] = []
for item in content:
text_part = None
text_part = item.get("text") if isinstance(item, dict) else self._get_optional_attr(item, "text")
if isinstance(text_part, str) and text_part.strip():
parts.append(text_part.strip())
return "\n".join(parts).strip()
return ""
@staticmethod
def _get_optional_attr(obj: Any, key: str) -> Any:
if obj is None:
return None
if isinstance(obj, dict):
return obj.get(key)
return getattr(obj, key, None)
+82
View File
@@ -0,0 +1,82 @@
"""Step 5 single-operator access control helpers."""
from __future__ import annotations
import base64
import binascii
import secrets
from fastapi import Request
from transcription.config import Settings
from transcription.errors import AppError
from transcription.errors import ErrorCategory
class AccessDeniedError(AppError):
"""Raised when a request is not authorized for operator actions."""
def __init__(self, message: str, *, suggestion: str, should_challenge: bool = True) -> None:
super().__init__(message, category=ErrorCategory.USER_INPUT, suggestion=suggestion)
self.should_challenge = should_challenge
def is_protected_path(path: str) -> bool:
"""Return True when a request path requires operator authentication."""
return path == "/ui" or path.startswith(("/ui/", "/api"))
def enforce_request_access(*, request: Request, settings: Settings) -> None:
"""Enforce basic operator access control for protected paths."""
if not settings.operator_access_enabled or not is_protected_path(request.url.path):
return
if not settings.operator_password:
raise AppError(
"Operator authentication is enabled but credentials are not configured",
category=ErrorCategory.INFRA_PERSISTENT,
suggestion="Set OPERATOR_PASSWORD in the runtime environment and restart the app.",
)
authorization = request.headers.get("Authorization")
username, password = _parse_basic_authorization_header(authorization)
valid_username = secrets.compare_digest(username, settings.operator_username)
valid_password = secrets.compare_digest(password, settings.operator_password)
if not (valid_username and valid_password):
raise AccessDeniedError(
"Invalid operator credentials",
suggestion="Provide valid operator credentials and retry.",
)
def _parse_basic_authorization_header(value: str | None) -> tuple[str, str]:
if not value:
raise AccessDeniedError(
"Operator authentication required",
suggestion="Provide HTTP Basic operator credentials and retry.",
)
scheme, _, token = value.partition(" ")
if scheme.lower() != "basic" or not token:
raise AccessDeniedError(
"Operator authentication required",
suggestion="Provide HTTP Basic operator credentials and retry.",
)
try:
decoded = base64.b64decode(token, validate=True).decode("utf-8")
except (binascii.Error, UnicodeDecodeError) as exc:
raise AccessDeniedError(
"Invalid authentication header",
suggestion="Provide HTTP Basic operator credentials and retry.",
) from exc
username, sep, password = decoded.partition(":")
if not sep or not username:
raise AccessDeniedError(
"Invalid authentication header",
suggestion="Provide HTTP Basic operator credentials and retry.",
)
return username, password
+25
View File
@@ -0,0 +1,25 @@
"""Service layer exports."""
from transcription.services.transcription import DEFAULT_PROMPT_FILE
from transcription.services.transcription import PromptLoadError
from transcription.services.transcription import TranscriptionError
from transcription.services.transcription import load_image_payload
from transcription.services.transcription import load_prompt_text
from transcription.services.transcription import transcribe_document_image
from transcription.services.upload import SUPPORTED_UPLOAD_EXTENSIONS
from transcription.services.upload import UploadError
from transcription.services.upload import UploadJobResult
from transcription.services.upload import create_upload_job
__all__ = [
"DEFAULT_PROMPT_FILE",
"SUPPORTED_UPLOAD_EXTENSIONS",
"PromptLoadError",
"TranscriptionError",
"UploadError",
"UploadJobResult",
"create_upload_job",
"load_image_payload",
"load_prompt_text",
"transcribe_document_image",
]
+264
View File
@@ -0,0 +1,264 @@
"""Step 3 functional services: job detail, revisions, search, and export."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC
from datetime import datetime
from uuid import UUID
from sqlmodel import Session
from sqlmodel import select
from transcription.db import get_session
from transcription.errors import AppError
from transcription.errors import ErrorCategory
from transcription.models import Document
from transcription.models import Job
from transcription.models import JobStatus
from transcription.models import Transcript
from transcription.models import TranscriptRevision
class LibraryError(AppError):
"""Base error for review/search/export service pathways."""
@dataclass(frozen=True)
class JobDetail:
"""Job detail read model including latest transcript and accepted revision."""
job: Job
document: Document | None
transcript: Transcript | None
accepted_revision: TranscriptRevision | None
def list_jobs(*, session: Session | None = None) -> list[Job]:
"""Return jobs in most-recent-first order."""
if session is None:
with get_session() as local_session:
return list_jobs(session=local_session)
return list(session.exec(select(Job).order_by(Job.created_at.desc())).all())
def get_job_detail(*, job_id: UUID, session: Session | None = None) -> JobDetail:
"""Fetch job detail with related document/transcript and accepted revision."""
if session is None:
with get_session() as local_session:
return get_job_detail(job_id=job_id, session=local_session)
job = session.get(Job, job_id)
if job is None:
raise LibraryError(
f"Job not found: {job_id}",
category=ErrorCategory.NOT_FOUND,
suggestion="Refresh jobs list and open a valid job id.",
)
document = session.get(Document, job.document_id)
transcript = session.exec(select(Transcript).where(Transcript.job_id == job.id)).first()
accepted_revision = session.exec(
select(TranscriptRevision)
.where(TranscriptRevision.job_id == job.id, TranscriptRevision.accepted.is_(True))
.order_by(TranscriptRevision.revision_number.desc())
).first()
return JobDetail(
job=job,
document=document,
transcript=transcript,
accepted_revision=accepted_revision,
)
def add_revision(
*,
job_id: UUID,
text: str,
source: str = "user",
accepted: bool = False,
session: Session | None = None,
) -> TranscriptRevision:
"""Append a transcript revision and optionally mark it as accepted."""
if not text.strip():
raise LibraryError(
"Revision text cannot be empty",
category=ErrorCategory.VALIDATION,
suggestion="Provide non-empty transcript text and retry.",
)
if session is None:
with get_session() as local_session:
return add_revision(
job_id=job_id,
text=text,
source=source,
accepted=accepted,
session=local_session,
)
job = session.get(Job, job_id)
if job is None:
raise LibraryError(
f"Job not found: {job_id}",
category=ErrorCategory.NOT_FOUND,
suggestion="Refresh jobs list and retry with a valid job id.",
)
revisions = list(
session.exec(
select(TranscriptRevision)
.where(TranscriptRevision.job_id == job_id)
.order_by(TranscriptRevision.revision_number)
).all()
)
next_revision_number = (revisions[-1].revision_number + 1) if revisions else 1
if accepted:
for existing in revisions:
if existing.accepted:
existing.accepted = False
session.add(existing)
revision = TranscriptRevision(
job_id=job_id,
revision_number=next_revision_number,
text=text,
source=source,
accepted=accepted,
)
session.add(revision)
transcript = session.exec(select(Transcript).where(Transcript.job_id == job_id)).first()
if transcript is None:
transcript = Transcript(job_id=job_id)
transcript.text = text
transcript.error_detail = None
session.add(transcript)
job.updated_at = datetime.now(UTC)
if accepted:
job.status = JobStatus.COMPLETED
elif job.status == JobStatus.QUEUED:
job.status = JobStatus.TRANSCRIBED
session.add(job)
session.commit()
session.refresh(revision)
return revision
def accept_revision(*, revision_id: UUID, session: Session | None = None) -> TranscriptRevision:
"""Mark one revision as accepted and synchronize canonical transcript/job state."""
if session is None:
with get_session() as local_session:
return accept_revision(revision_id=revision_id, session=local_session)
revision = session.get(TranscriptRevision, revision_id)
if revision is None:
raise LibraryError(
f"Revision not found: {revision_id}",
category=ErrorCategory.NOT_FOUND,
suggestion="Refresh job detail and select a valid revision.",
)
all_revisions = list(session.exec(select(TranscriptRevision).where(TranscriptRevision.job_id == revision.job_id)).all())
for item in all_revisions:
item.accepted = item.id == revision.id
session.add(item)
transcript = session.exec(select(Transcript).where(Transcript.job_id == revision.job_id)).first()
if transcript is None:
transcript = Transcript(job_id=revision.job_id)
transcript.text = revision.text
transcript.error_detail = None
session.add(transcript)
job = session.get(Job, revision.job_id)
if job is not None:
job.status = JobStatus.COMPLETED
job.updated_at = datetime.now(UTC)
session.add(job)
session.commit()
session.refresh(revision)
return revision
def list_revisions(*, job_id: UUID, session: Session | None = None) -> list[TranscriptRevision]:
"""Return revision history for a job in ascending revision order."""
if session is None:
with get_session() as local_session:
return list_revisions(job_id=job_id, session=local_session)
if session.get(Job, job_id) is None:
raise LibraryError(
f"Job not found: {job_id}",
category=ErrorCategory.NOT_FOUND,
suggestion="Refresh jobs list and open a valid job id.",
)
return list(
session.exec(
select(TranscriptRevision)
.where(TranscriptRevision.job_id == job_id)
.order_by(TranscriptRevision.revision_number)
).all()
)
def search_accepted_transcripts(*, query: str, session: Session | None = None) -> list[TranscriptRevision]:
"""Search accepted transcript revisions using case-insensitive text containment."""
if not query.strip():
raise LibraryError(
"Search query cannot be empty",
category=ErrorCategory.VALIDATION,
suggestion="Enter a non-empty search query and retry.",
)
if session is None:
with get_session() as local_session:
return search_accepted_transcripts(query=query, session=local_session)
pattern = f"%{query.strip()}%"
return list(
session.exec(
select(TranscriptRevision)
.where(TranscriptRevision.accepted.is_(True), TranscriptRevision.text.ilike(pattern))
.order_by(TranscriptRevision.created_at.desc())
).all()
)
def export_transcripts(*, accepted_only: bool = True, session: Session | None = None) -> list[dict[str, str | int | None]]:
"""Export transcript data as serializable records for archive workflows."""
if session is None:
with get_session() as local_session:
return export_transcripts(accepted_only=accepted_only, session=local_session)
statement = select(TranscriptRevision).order_by(TranscriptRevision.created_at)
if accepted_only:
statement = statement.where(TranscriptRevision.accepted.is_(True))
revisions = list(session.exec(statement).all())
payload: list[dict[str, str | int | None]] = []
for revision in revisions:
detail = get_job_detail(job_id=revision.job_id, session=session)
payload.append(
{
"job_id": str(revision.job_id),
"document_id": str(detail.job.document_id),
"filename": detail.document.filename if detail.document else None,
"revision_id": str(revision.id),
"revision_number": revision.revision_number,
"accepted": revision.accepted,
"source": revision.source,
"text": revision.text,
"created_at": revision.created_at.isoformat(),
}
)
return payload
+133
View File
@@ -0,0 +1,133 @@
"""Prompt loading and provider-backed transcription service."""
from __future__ import annotations
import logging
import mimetypes
from pathlib import Path
from transcription.config import Settings
from transcription.config import get_settings
from transcription.errors import AppError
from transcription.errors import ErrorCategory
from transcription.providers import ProviderAuthError
from transcription.providers import ProviderError
from transcription.providers import ProviderResponseError
from transcription.providers import TranscriptionProvider
from transcription.providers import TranscriptionResult
from transcription.providers import get_transcription_provider
logger = logging.getLogger(__name__)
DEFAULT_PROMPT_FILE = "transcribe_document.md"
SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".pdf"}
class PromptLoadError(AppError):
"""Raised when prompt artifacts cannot be loaded safely."""
class TranscriptionError(AppError):
"""Raised when transcription execution fails."""
def load_prompt_text(*, prompt_name: str = DEFAULT_PROMPT_FILE, settings: Settings | None = None) -> str:
"""Load and validate prompt text from PROMPT_DIR."""
runtime_settings = settings or get_settings()
prompt_path = runtime_settings.prompt_dir / prompt_name
if not prompt_path.exists() or not prompt_path.is_file():
raise PromptLoadError(
f"Prompt file not found: {prompt_path}",
category=ErrorCategory.INFRA_PERSISTENT,
suggestion="Verify PROMPT_DIR and prompt file configuration, then retry.",
)
prompt_text = prompt_path.read_text(encoding="utf-8").strip()
if not prompt_text:
raise PromptLoadError(
f"Prompt file is empty: {prompt_path}",
category=ErrorCategory.VALIDATION,
suggestion="Populate the prompt file with valid instructions and retry.",
)
logger.info("Loaded prompt artifact: %s", prompt_path)
return prompt_text
def load_image_payload(image_path: str | Path) -> tuple[bytes, str]:
"""Read image bytes and detect mime type for supported uploads."""
path = Path(image_path)
if not path.exists() or not path.is_file():
raise TranscriptionError(
f"Image file not found: {path}",
category=ErrorCategory.NOT_FOUND,
suggestion="Verify the uploaded file exists and retry from the jobs page.",
)
suffix = path.suffix.lower()
if suffix not in SUPPORTED_EXTENSIONS:
raise TranscriptionError(
f"Unsupported file type: {suffix}",
category=ErrorCategory.USER_INPUT,
suggestion="Use JPG, JPEG, PNG, TIFF, or PDF files.",
)
mime_type, _ = mimetypes.guess_type(path.name)
if suffix in {".tif", ".tiff"}:
mime_type = "image/tiff"
if not mime_type:
raise TranscriptionError(
f"Unable to determine MIME type for: {path}",
category=ErrorCategory.VALIDATION,
suggestion="Re-save the file in a supported format and retry.",
)
return path.read_bytes(), mime_type
def transcribe_document_image(
image_path: str | Path,
*,
prompt_name: str = DEFAULT_PROMPT_FILE,
settings: Settings | None = None,
provider: TranscriptionProvider | None = None,
) -> TranscriptionResult:
"""Transcribe a local image using the configured prompt and provider."""
runtime_settings = settings or get_settings()
prompt_text = load_prompt_text(prompt_name=prompt_name, settings=runtime_settings)
image_bytes, mime_type = load_image_payload(image_path)
adapter = provider or get_transcription_provider(settings=runtime_settings)
logger.info("Starting transcription for image=%s mime_type=%s", image_path, mime_type)
try:
result = adapter.transcribe(
prompt_text=prompt_text,
image_bytes=image_bytes,
mime_type=mime_type,
)
except ProviderAuthError as exc:
raise TranscriptionError(
"Provider authentication failed",
category=ErrorCategory.INFRA_PERSISTENT,
suggestion="Verify provider API credentials and retry.",
) from exc
except ProviderResponseError as exc:
raise TranscriptionError(
"Provider returned an invalid response",
category=ErrorCategory.EXTERNAL_PROVIDER,
suggestion="Retry once. If it persists, try a different provider model or inspect provider status.",
retriable=True,
) from exc
except ProviderError as exc:
raise TranscriptionError(
"Provider transcription failed",
category=ErrorCategory.EXTERNAL_PROVIDER,
suggestion="Retry the transcription from jobs. If repeated, check provider availability.",
retriable=True,
) from exc
logger.info("Transcription completed for image=%s provider=%s", image_path, result.provider)
return result
+169
View File
@@ -0,0 +1,169 @@
"""Upload service for storing files and creating queued transcription jobs."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from pathlib import Path
from uuid import UUID
from uuid import uuid4
from sqlmodel.ext.asyncio.session import AsyncSession
from transcription.config import Settings
from transcription.config import get_settings
from transcription.db import get_session
from transcription.errors import AppError
from transcription.errors import ErrorCategory
from transcription.models import Document
from transcription.models import Job
from transcription.models import JobStatus
logger = logging.getLogger(__name__)
SUPPORTED_UPLOAD_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".pdf"}
class UploadError(AppError):
"""Raised when uploaded content cannot be persisted safely."""
@dataclass(frozen=True)
class UploadJobResult:
"""Summary of created upload records."""
document_id: UUID
job_id: UUID
stored_path: Path
original_filename: str
async def create_upload_job(
*,
filename: str,
file_bytes: bytes,
session: AsyncSession | None = None,
settings: Settings | None = None,
) -> UploadJobResult:
"""Persist an uploaded file and create document/job records."""
runtime_settings = settings or get_settings()
_validate_upload(
filename=filename,
file_bytes=file_bytes,
max_upload_bytes=runtime_settings.max_upload_bytes,
)
upload_dir = runtime_settings.upload_dir
upload_dir.mkdir(parents=True, exist_ok=True)
stored_name = _build_stored_filename(filename)
stored_path = upload_dir / stored_name
try:
stored_path.write_bytes(file_bytes)
except OSError as exc:
raise UploadError(
"Failed to persist upload file",
category=ErrorCategory.INFRA_PERSISTENT,
suggestion="Check upload directory permissions and available disk space, then retry.",
) from exc
try:
if session is not None:
document, job = await _create_upload_records(
session=session,
original_filename=filename,
stored_path=stored_path,
)
else:
async with get_session() as local_session:
document, job = await _create_upload_records(
session=local_session,
original_filename=filename,
stored_path=stored_path,
)
except Exception as exc:
_best_effort_delete(stored_path)
raise UploadError(
"Failed to create upload database records",
category=ErrorCategory.INFRA_TRANSIENT,
suggestion="Retry upload. If this keeps happening, verify database availability.",
retriable=True,
) from exc
logger.info("Created upload job document_id=%s job_id=%s", document.id, job.id)
return UploadJobResult(
document_id=document.id,
job_id=job.id,
stored_path=stored_path,
original_filename=Path(filename).name,
)
def _validate_upload(*, filename: str, file_bytes: bytes, max_upload_bytes: int) -> None:
if not file_bytes:
raise UploadError(
"Upload payload is empty",
category=ErrorCategory.VALIDATION,
suggestion="Select a non-empty file and try again.",
)
if len(file_bytes) > max_upload_bytes:
raise UploadError(
f"Upload exceeds maximum allowed size ({max_upload_bytes} bytes)",
category=ErrorCategory.USER_INPUT,
suggestion="Upload a smaller file or increase MAX_UPLOAD_BYTES for this deployment.",
)
safe_name = Path(filename).name
if not safe_name:
raise UploadError(
"Upload filename is required",
category=ErrorCategory.VALIDATION,
suggestion="Choose a file with a valid filename and retry.",
)
suffix = Path(safe_name).suffix.lower()
if suffix not in SUPPORTED_UPLOAD_EXTENSIONS:
raise UploadError(
f"Unsupported upload extension: {suffix}",
category=ErrorCategory.USER_INPUT,
suggestion="Upload JPG, JPEG, PNG, TIFF, or PDF files only.",
)
def _build_stored_filename(filename: str) -> str:
safe_name = Path(filename).name
return f"{uuid4()}_{safe_name}"
async def _create_upload_records(
*,
session: AsyncSession,
original_filename: str,
stored_path: Path,
) -> tuple[Document, Job]:
document = Document(
filename=Path(original_filename).name,
file_path=str(stored_path),
)
session.add(document)
await session.flush()
job = Job(
document_id=document.id,
status=JobStatus.QUEUED,
)
session.add(job)
await session.commit()
await session.refresh(document)
await session.refresh(job)
return document, job
def _best_effort_delete(path: Path) -> None:
try:
if path.exists():
path.unlink()
except OSError:
logger.warning("Failed to clean up upload file after DB error: %s", path)
+14
View File
@@ -0,0 +1,14 @@
"""UI page registration exports."""
from fastapi import FastAPI
from nicegui import ui
from transcription.ui.pages.jobs_page import register_page as register_jobs_page
from transcription.ui.pages.upload_page import register_page as register_upload_page
def register_pages(app: FastAPI) -> None:
"""Register all NiceGUI pages and mount them onto the FastAPI app."""
register_upload_page()
register_jobs_page()
ui.run_with(app, mount_path="/ui", show_welcome_message=False)
@@ -0,0 +1,42 @@
"""Shared UI error rendering helpers."""
from __future__ import annotations
from nicegui import ui
from transcription.errors import AppError
from transcription.errors import ErrorCategory
from transcription.errors import classify_unexpected_error
def to_app_error(exc: Exception, *, operation: str) -> AppError:
"""Normalize any exception for consistent UI display."""
if isinstance(exc, AppError):
return exc
return classify_unexpected_error(exc, operation=operation)
def show_error(exc: Exception, *, title: str, operation: str) -> None:
"""Display a visible, actionable UI error with trace id."""
error = to_app_error(exc, operation=operation)
ui.notify(
f"{title}: {error.message} (ref: {error.error_id})",
type="negative",
timeout=0,
close_button="Dismiss",
)
with ui.card().classes("bg-red-1 text-red-10 q-mt-md q-pa-md"):
ui.label(title).classes("text-subtitle1")
ui.label(error.message)
ui.label(f"Suggested action: {error.suggestion}").classes("text-weight-medium")
ui.label(f"Error reference: {error.error_id}").classes("text-caption")
ui.label(f"Category: {error.category.value}").classes("text-caption")
def summarize_error(exc: Exception, *, operation: str) -> str:
"""Return short one-line summary for status labels."""
error = to_app_error(exc, operation=operation)
if error.category == ErrorCategory.INTERNAL_UNEXPECTED:
return f"Unexpected error (ref: {error.error_id})"
return f"{error.message} (ref: {error.error_id})"
@@ -0,0 +1,30 @@
"""Reusable job detail rendering helpers."""
from __future__ import annotations
from nicegui import ui
from transcription.models import Document
from transcription.models import Job
from transcription.models import Transcript
def render_job_detail(*, job: Job, document: Document | None, transcript: Transcript | None) -> None:
"""Render all sections for the job detail page."""
ui.label(f"Job ID: {job.id}")
ui.label(f"Status: {job.status.value}")
ui.label(f"Created: {job.created_at.isoformat()}")
ui.label(f"Updated: {job.updated_at.isoformat()}")
if document is not None:
ui.label(f"Filename: {document.filename}")
ui.label(f"File path: {document.file_path}")
if transcript is None:
ui.label("Transcript not available yet.")
elif transcript.text:
ui.label("Transcript:")
ui.markdown(transcript.text)
elif transcript.error_detail:
ui.label("Failure detail:")
ui.label(transcript.error_detail)
@@ -0,0 +1,55 @@
"""Reusable jobs table rendering helpers."""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from uuid import UUID
from nicegui import ui
@dataclass(frozen=True)
class JobTableRow:
"""Read model consumed by the shared jobs table component."""
id: UUID
status: str
created_at: str
updated_at: str
def _serialize_rows(rows: Sequence[JobTableRow]) -> list[dict[str, str]]:
"""Convert typed rows into table-compatible dictionaries."""
return [
{
"id": str(row.id),
"status": row.status,
"created_at": row.created_at,
"updated_at": row.updated_at,
}
for row in rows
]
def render_jobs_table(rows: Sequence[JobTableRow]) -> None:
"""Render jobs table and per-row detail links."""
if not rows:
ui.label("No jobs yet.")
return
serialized_rows = _serialize_rows(rows)
ui.table(
columns=[
{"name": "id", "label": "Job ID", "field": "id"},
{"name": "status", "label": "Status", "field": "status"},
{"name": "created_at", "label": "Created", "field": "created_at"},
{"name": "updated_at", "label": "Updated", "field": "updated_at"},
],
rows=serialized_rows,
row_key="id",
).classes("w-full")
with ui.column().classes("gap-1"):
for row in serialized_rows:
ui.link(f"Open {row['id']}", f"/jobs/{row['id']}")
+245
View File
@@ -0,0 +1,245 @@
"""Jobs list and detail page registration."""
from __future__ import annotations
from dataclasses import dataclass
from uuid import UUID
from nicegui import ui
from sqlmodel import select
from transcription.db import get_session
from transcription.models import Document
from transcription.models import Job
from transcription.models import Transcript
from transcription.services.library import accept_revision
from transcription.services.library import add_revision
from transcription.services.library import export_transcripts
from transcription.services.library import list_revisions
from transcription.services.library import search_accepted_transcripts
from transcription.ui.error_presenter import show_error
from transcription.ui.error_presenter import summarize_error
@dataclass(frozen=True)
class JobView:
"""Read model for rendering job rows in the UI."""
id: UUID
status: str
created_at: str
updated_at: str
def fetch_jobs() -> list[JobView]:
"""Return jobs for display in most-recent-first order."""
with get_session() as session:
jobs = session.exec(select(Job).order_by(Job.created_at.desc())).all()
return [
JobView(
id=job.id,
status=job.status.value,
created_at=job.created_at.isoformat(),
updated_at=job.updated_at.isoformat(),
)
for job in jobs
]
def fetch_job_detail(job_id: UUID) -> tuple[Job | None, Document | None, Transcript | None]:
"""Return job, document, and transcript for detail view."""
with get_session() as session:
job = session.get(Job, job_id)
if job is None:
return None, None, None
document = session.get(Document, job.document_id)
transcript = session.exec(select(Transcript).where(Transcript.job_id == job.id)).first()
return job, document, transcript
def register_page() -> None:
"""Register jobs list and detail routes."""
@ui.page("/jobs")
def jobs_page() -> None:
ui.label("Transcription Jobs")
status = ui.label("Ready")
table_container = ui.column()
def render_table() -> None:
table_container.clear()
jobs = fetch_jobs()
with table_container:
if not jobs:
ui.label("No jobs yet.")
return
rows = [
{
"id": str(job.id),
"status": job.status,
"created_at": job.created_at,
"updated_at": job.updated_at,
}
for job in jobs
]
ui.table(
columns=[
{"name": "id", "label": "Job ID", "field": "id"},
{"name": "status", "label": "Status", "field": "status"},
{"name": "created_at", "label": "Created", "field": "created_at"},
{"name": "updated_at", "label": "Updated", "field": "updated_at"},
],
rows=rows,
row_key="id",
)
for row in rows:
ui.link(f"Open {row['id']}", f"/jobs/{row['id']}")
def refresh() -> None:
status.text = "Refreshing..."
try:
render_table()
status.text = "Refreshed"
except Exception as exc: # noqa: BLE001
status.text = f"Refresh failed: {summarize_error(exc, operation='jobs.refresh')}"
show_error(exc, title="Jobs refresh failed", operation="jobs.refresh")
ui.button("Refresh", on_click=refresh)
render_table()
ui.link("Back to upload", "/")
@ui.page("/jobs/{job_id}")
def job_detail_page(job_id: str) -> None:
ui.label("Job Detail")
try:
parsed_id = UUID(job_id)
except ValueError:
ui.label("Invalid job id")
ui.link("Back to jobs", "/jobs")
return
job, document, transcript = fetch_job_detail(parsed_id)
if job is None:
ui.label("Job not found")
ui.link("Back to jobs", "/jobs")
return
ui.label(f"Job ID: {job.id}")
ui.label(f"Status: {job.status.value}")
ui.label(f"Created: {job.created_at.isoformat()}")
ui.label(f"Updated: {job.updated_at.isoformat()}")
if document is not None:
ui.label(f"Filename: {document.filename}")
ui.label(f"File path: {document.file_path}")
if transcript is None:
ui.label("Transcript not available yet.")
elif transcript.text:
ui.label("Transcript:")
ui.markdown(transcript.text)
elif transcript.error_detail:
ui.label("Failure detail:")
ui.label(transcript.error_detail)
ui.separator()
ui.label("Revision History")
revisions_container = ui.column()
def render_revisions() -> None:
revisions_container.clear()
with revisions_container:
revisions = list_revisions(job_id=parsed_id)
if not revisions:
ui.label("No revisions yet.")
return
for revision in revisions:
with ui.card().classes("w-full"):
ui.label(
f"Revision {revision.revision_number} | source={revision.source} | accepted={revision.accepted}"
)
ui.markdown(revision.text)
if not revision.accepted:
ui.button(
"Accept revision",
on_click=lambda rev_id=revision.id: _accept_revision(rev_id),
)
def _accept_revision(revision_id):
try:
accept_revision(revision_id=revision_id)
ui.notify("Revision accepted", type="positive")
render_revisions()
except Exception as exc: # noqa: BLE001
show_error(exc, title="Accept revision failed", operation="revisions.accept")
new_revision_text = ui.textarea("Add revision text").props("rows=6")
def _submit_revision() -> None:
try:
add_revision(job_id=parsed_id, text=new_revision_text.value or "", source="user", accepted=False)
new_revision_text.value = ""
ui.notify("Revision added", type="positive")
render_revisions()
except Exception as exc: # noqa: BLE001
show_error(exc, title="Add revision failed", operation="revisions.create")
ui.button("Add revision", on_click=_submit_revision)
render_revisions()
ui.link("Search transcripts", "/search")
ui.link("Export transcripts", "/export")
ui.link("Back to jobs", "/jobs")
@ui.page("/search")
def search_page() -> None:
ui.label("Search Accepted Transcripts")
query_input = ui.input("Search query")
results_container = ui.column()
def run_search() -> None:
results_container.clear()
try:
results = search_accepted_transcripts(query=query_input.value or "")
except Exception as exc: # noqa: BLE001
show_error(exc, title="Search failed", operation="search.run")
return
with results_container:
if not results:
ui.label("No results.")
return
for result in results:
with ui.card().classes("w-full"):
ui.label(f"Job {result.job_id} | Revision {result.revision_number}")
ui.markdown(result.text)
ui.button("Search", on_click=run_search)
ui.link("Back to jobs", "/jobs")
@ui.page("/export")
def export_page() -> None:
ui.label("Export Accepted Transcripts")
results_container = ui.column()
def run_export() -> None:
results_container.clear()
try:
records = export_transcripts(accepted_only=True)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Export failed", operation="export.run")
return
with results_container:
ui.label(f"Exported records: {len(records)}")
for record in records:
with ui.card().classes("w-full"):
ui.label(f"{record['filename']} | Revision {record['revision_number']}")
ui.markdown(str(record["text"]))
ui.button("Run export", on_click=run_export)
ui.link("Back to jobs", "/jobs")
+92
View File
@@ -0,0 +1,92 @@
"""Jobs list and detail page registration."""
from __future__ import annotations
from uuid import UUID
from nicegui import ui
from sqlmodel import desc
from sqlmodel import select
from transcription.db import get_session
from transcription.models import Document
from transcription.models import Job
from transcription.models import Transcript
from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.error_presenter import summarize_error
from transcription.ui.components.job_detail import render_job_detail
from transcription.ui.components.job_table import JobTableRow
from transcription.ui.components.job_table import render_jobs_table
async def fetch_jobs() -> list[JobTableRow]:
"""Return jobs for display in most-recent-first order."""
async with get_session() as session:
jobs = (await session.exec(select(Job).order_by(desc(Job.created_at)))).all()
return [
JobTableRow(
id=job.id,
status=job.status.value,
created_at=job.created_at.isoformat(),
updated_at=job.updated_at.isoformat(),
)
for job in jobs
]
async def fetch_job_detail(job_id: UUID) -> tuple[Job | None, Document | None, Transcript | None]:
"""Return job, document, and transcript for detail view."""
async with get_session() as session:
job = await session.get(Job, job_id)
if job is None:
return None, None, None
document = await session.get(Document, job.document_id)
transcript = (await session.exec(select(Transcript).where(Transcript.job_id == job.id))).first()
return job, document, transcript
def register_page() -> None:
"""Register jobs list and detail routes."""
@ui.page("/jobs")
async def jobs_page() -> None:
ui.label("Transcription Jobs")
status = ui.label("Ready")
@ui.refreshable
async def render_table() -> None:
jobs = await fetch_jobs()
render_jobs_table(jobs)
async def refresh() -> None:
status.text = "Refreshing..."
try:
await render_table.refresh()
status.text = "Refreshed"
except Exception as exc: # noqa: BLE001
status.text = f"Refresh failed: {summarize_error(exc, operation='jobs.refresh')}"
show_error(exc, title="Jobs refresh failed", operation="jobs.refresh")
ui.button("Refresh", on_click=refresh)
await render_table()
ui.link("Back to upload", "/")
@ui.page("/jobs/{job_id}")
async def job_detail_page(job_id: str) -> None:
ui.label("Job Detail")
try:
parsed_id = UUID(job_id)
except ValueError:
ui.label("Invalid job id")
ui.link("Back to jobs", "/jobs")
return
job, document, transcript = await fetch_job_detail(parsed_id)
if job is None:
ui.label("Job not found")
ui.link("Back to jobs", "/jobs")
return
render_job_detail(job=job, document=document, transcript=transcript)
ui.link("Back to jobs", "/jobs")
+74
View File
@@ -0,0 +1,74 @@
"""Upload page registration and handlers."""
from __future__ import annotations
from dataclasses import dataclass
from nicegui import ui
from nicegui.events import UploadEventArguments
from transcription.services.upload import UploadError
from transcription.services.upload import UploadJobResult
from transcription.services.upload import create_upload_job
from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.error_presenter import summarize_error
@dataclass
class UploadPageState:
"""Simple state container for upload page feedback."""
loading: bool = False
message: str = ""
def accepted_upload_types() -> str:
"""Return accepted file type string for upload input."""
return ".jpg,.jpeg,.png,.tif,.tiff,.pdf"
async def submit_upload(*, filename: str, file_bytes: bytes) -> UploadJobResult:
"""Create an upload job from incoming file data."""
return await create_upload_job(filename=filename, file_bytes=file_bytes)
def register_page() -> None:
"""Register the upload page route."""
@ui.page("/")
def upload_page() -> None:
state = UploadPageState()
status_label = ui.label("Upload a document to start transcription.")
async def on_upload(event: UploadEventArguments) -> None:
if state.loading:
ui.notify("Upload already in progress. Please wait.", type="warning")
return
state.loading = True
status_label.text = "Uploading..."
try:
payload = await event.file.read()
result = await submit_upload(filename=event.file.name, file_bytes=payload)
state.message = f"Created job {result.job_id}"
status_label.text = state.message
ui.notify(state.message, type="positive")
except UploadError as exc:
state.message = summarize_error(exc, operation="upload.submit")
status_label.text = f"Upload failed: {state.message}"
show_error(exc, title="Upload failed", operation="upload.submit")
except Exception as exc: # noqa: BLE001
state.message = summarize_error(exc, operation="upload.submit")
status_label.text = f"Upload failed: {state.message}"
show_error(exc, title="Upload failed", operation="upload.submit")
finally:
state.loading = False
ui.upload(
on_upload=on_upload,
auto_upload=True,
label="Select document file",
).props(f"accept={accepted_upload_types()}")
with ui.row():
ui.link("View jobs", "/jobs")
+191
View File
@@ -0,0 +1,191 @@
"""Background worker for queued transcription jobs."""
from __future__ import annotations
import asyncio
import logging
from datetime import UTC
from datetime import datetime
from threading import Event
from pydantic import ValidationError
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from transcription.config import Settings
from transcription.config import get_settings
from transcription.db import get_session
from transcription.errors import AppError
from transcription.errors import ErrorCategory
from transcription.errors import classify_unexpected_error
from transcription.errors import format_error_detail
from transcription.models import Document
from transcription.models import Job
from transcription.models import JobStatus
from transcription.models import Transcript
from transcription.services.transcription import transcribe_document_image
logger = logging.getLogger(__name__)
async def process_next_queued_job(
*,
session: AsyncSession | None = None,
session_factory: async_sessionmaker[AsyncSession] | None = None,
) -> bool:
"""Process the next queued job and persist terminal outcome.
Returns True when a job was processed, False when no queued job exists.
"""
if session is None:
async with get_session(session_factory=session_factory) as local_session:
return await _process_next_queued_job(session=local_session)
return await _process_next_queued_job(session=session)
async def _process_next_queued_job(*, session: AsyncSession) -> bool:
job = (await session.exec(select(Job).where(Job.status == JobStatus.QUEUED).order_by(Job.created_at))).first()
if job is None:
return False
logger.info("Picked queued job operation=worker.pick job_id=%s", job.id)
job.status = JobStatus.PROCESSING
job.updated_at = datetime.now(UTC)
session.add(job)
await session.commit()
await session.refresh(job)
document = await session.get(Document, job.document_id)
if document is None:
error = AppError(
"Document not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Re-upload the source document and retry processing.",
)
_finalize_failed_job(session=session, job=job, error=error)
logger.error(
"Job failed operation=worker.process_job job_id=%s error_id=%s category=%s",
job.id,
error.error_id,
error.category.value,
)
return True
try:
result = transcribe_document_image(document.file_path)
await _upsert_transcript(session=session, job_id=job.id, text=result.text, error_detail=None)
job.status = JobStatus.TRANSCRIBED
job.updated_at = datetime.now(UTC)
session.add(job)
await session.commit()
logger.info(
"Job transcribed operation=worker.process_job job_id=%s document_id=%s provider=%s revision_number=%s",
job.id,
document.id,
result.provider,
revision.revision_number,
)
except Exception as exc:
error = exc if isinstance(exc, AppError) else classify_unexpected_error(exc, operation="worker.process_job")
settings = _get_worker_settings()
if _should_retry(job=job, error=error, settings=settings):
await _requeue_for_retry(session=session, job=job, error=error, settings=settings)
logger.warning(
"Job retried operation=worker.process_job job_id=%s document_id=%s retry_count=%s error_id=%s category=%s",
job.id,
document.id,
job.retry_count,
error.error_id,
error.category.value,
)
else:
await _finalize_failed_job(session=session, job=job, error=error)
logger.exception(
"Job failed operation=worker.process_job job_id=%s document_id=%s error_id=%s category=%s",
job.id,
document.id,
error.error_id,
error.category.value,
)
return True
async def _upsert_transcript(
*, session: AsyncSession, job_id, text: str | None, error_detail: str | None
) -> Transcript:
transcript = (await session.exec(select(Transcript).where(Transcript.job_id == job_id))).first()
if transcript is None:
transcript = Transcript(job_id=job_id)
transcript.text = text
transcript.error_detail = error_detail
session.add(transcript)
await session.commit()
await session.refresh(transcript)
return transcript
def _get_worker_settings() -> Settings:
try:
return get_settings()
except ValidationError:
return Settings(openrouter_api_key="test-key")
def _should_retry(*, job: Job, error: AppError, settings: Settings) -> bool:
return error.retriable and job.retry_count < settings.worker_max_retries
async def _requeue_for_retry(*, session: AsyncSession, job: Job, error: AppError, settings: Settings) -> None:
await _upsert_transcript(session=session, job_id=job.id, text=None, error_detail=format_error_detail(error))
job.retry_count += 1
job.status = JobStatus.QUEUED
job.updated_at = datetime.now(UTC)
session.add(job)
await session.commit()
if settings.worker_retry_backoff_seconds > 0:
await asyncio.sleep(settings.worker_retry_backoff_seconds)
async def _finalize_failed_job(*, session: AsyncSession, job: Job, error: AppError) -> None:
await _upsert_transcript(session=session, job_id=job.id, text=None, error_detail=format_error_detail(error))
job.status = JobStatus.FAILED
job.updated_at = datetime.now(UTC)
session.add(job)
await session.commit()
async def _run_worker_loop_async(
*,
session_factory: async_sessionmaker[AsyncSession] | None = None,
stop_event: Event | None = None,
poll_interval_seconds: float = 1.0,
) -> None:
"""Run worker polling loop until stop_event is set."""
while True:
if stop_event is not None and stop_event.is_set():
logger.info("Worker stop event received")
return
processed = await process_next_queued_job(session_factory=session_factory)
if not processed:
await asyncio.sleep(poll_interval_seconds)
def run_worker_loop(
*,
session_factory: async_sessionmaker[AsyncSession] | None = None,
stop_event: Event | None = None,
poll_interval_seconds: float = 1.0,
) -> None:
"""Synchronous thread entrypoint that runs the async worker loop."""
asyncio.run(
_run_worker_loop_async(
session_factory=session_factory,
stop_event=stop_event,
poll_interval_seconds=poll_interval_seconds,
)
)
View File
+129
View File
@@ -0,0 +1,129 @@
"""Tests for Step 5 operator access control behavior."""
from __future__ import annotations
import base64
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
from transcription.api.errors import register_error_handlers
from transcription.errors import build_error_envelope
from transcription.security import AccessDeniedError
from transcription.security import enforce_request_access
def _basic_header(username: str, password: str) -> str:
token = base64.b64encode(f"{username}:{password}".encode()).decode("ascii")
return f"Basic {token}"
def _build_app(*, settings) -> FastAPI:
app = FastAPI()
register_error_handlers(app)
@app.middleware("http")
async def operator_access_middleware(request, call_next):
try:
enforce_request_access(request=request, settings=settings)
except AccessDeniedError as exc:
envelope = build_error_envelope(exc)
headers = {"WWW-Authenticate": "Basic"} if exc.should_challenge else None
return JSONResponse(status_code=401, content=envelope.__dict__, headers=headers)
return await call_next(request)
@app.get("/healthz")
def healthz():
return {"status": "ok"}
@app.get("/api/jobs")
def get_jobs():
return [{"id": "demo"}]
@app.get("/ui")
def ui_root():
return {"ok": True}
return app
@pytest.mark.integration
class TestAccessControl:
"""Verify protected routes enforce operator auth when enabled."""
def test_protected_api_requires_credentials(self):
settings = SimpleNamespace(
operator_access_enabled=True,
operator_username="operator",
operator_password="secret",
)
client = TestClient(_build_app(settings=settings), raise_server_exceptions=False)
response = client.get("/api/jobs")
assert response.status_code == 401
assert response.headers.get("WWW-Authenticate") == "Basic"
payload = response.json()
assert payload["category"] == "user_input_error"
assert payload["suggestion"]
def test_protected_api_rejects_invalid_credentials(self):
settings = SimpleNamespace(
operator_access_enabled=True,
operator_username="operator",
operator_password="secret",
)
client = TestClient(_build_app(settings=settings), raise_server_exceptions=False)
response = client.get(
"/api/jobs",
headers={"Authorization": _basic_header("operator", "wrong")},
)
assert response.status_code == 401
payload = response.json()
assert payload["message"] == "Invalid operator credentials"
def test_protected_api_allows_valid_credentials(self):
settings = SimpleNamespace(
operator_access_enabled=True,
operator_username="operator",
operator_password="secret",
)
client = TestClient(_build_app(settings=settings), raise_server_exceptions=False)
response = client.get(
"/api/jobs",
headers={"Authorization": _basic_header("operator", "secret")},
)
assert response.status_code == 200
assert response.json() == [{"id": "demo"}]
def test_protected_ui_path_requires_credentials(self):
settings = SimpleNamespace(
operator_access_enabled=True,
operator_username="operator",
operator_password="secret",
)
client = TestClient(_build_app(settings=settings), raise_server_exceptions=False)
response = client.get("/ui")
assert response.status_code == 401
def test_healthz_is_not_protected(self):
settings = SimpleNamespace(
operator_access_enabled=True,
operator_username="operator",
operator_password="secret",
)
client = TestClient(_build_app(settings=settings), raise_server_exceptions=False)
response = client.get("/healthz")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
+57
View File
@@ -0,0 +1,57 @@
"""Tests for API error response envelope handlers."""
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from transcription.api.errors import register_error_handlers
from transcription.errors import AppError
from transcription.errors import ErrorCategory
@pytest.mark.integration
class TestApiErrorResponses:
"""Verify API-level error serialization and status mapping."""
def test_app_error_returns_structured_envelope(self):
"""AppError maps to policy envelope fields and status code."""
app = FastAPI()
register_error_handlers(app)
@app.get("/boom")
def boom() -> dict[str, str]:
raise AppError(
"Bad upload payload",
category=ErrorCategory.VALIDATION,
suggestion="Upload a non-empty file",
error_id="abc12345",
)
client = TestClient(app)
response = client.get("/boom")
assert response.status_code == 400
payload = response.json()
assert payload["error_id"] == "abc12345"
assert payload["category"] == "validation_error"
assert payload["message"] == "Bad upload payload"
assert payload["suggestion"] == "Upload a non-empty file"
assert "timestamp" in payload
def test_unexpected_error_returns_internal_unexpected_envelope(self):
"""Unexpected exceptions map to internal_unexpected_error with 500."""
app = FastAPI()
register_error_handlers(app)
@app.get("/explode")
def explode() -> dict[str, str]:
raise RuntimeError("unexpected failure")
client = TestClient(app, raise_server_exceptions=False)
response = client.get("/explode")
assert response.status_code == 500
payload = response.json()
assert payload["category"] == "internal_unexpected_error"
assert "error_id" in payload
assert payload["suggestion"]
+21
View File
@@ -0,0 +1,21 @@
"""Tests for transcription.api.health."""
from fastapi import FastAPI
from fastapi.testclient import TestClient
from transcription.api.health import router
class TestHealthEndpoint:
"""Verify /healthz endpoint behavior."""
def test_healthz_returns_ok_status(self):
"""GET /healthz returns a healthy status payload."""
app = FastAPI()
app.include_router(router)
client = TestClient(app)
response = client.get("/healthz")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
+119
View File
@@ -0,0 +1,119 @@
"""Tests for Step 3 functional API routes."""
from datetime import UTC
from datetime import datetime
from types import SimpleNamespace
from uuid import uuid4
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from transcription.api.errors import register_error_handlers
from transcription.api.routes import router
def _build_app() -> FastAPI:
app = FastAPI()
register_error_handlers(app)
app.include_router(router)
return app
@pytest.mark.integration
class TestFunctionalRoutes:
"""Verify jobs/revisions/search/export route behavior."""
def test_get_jobs_returns_serialized_rows(self, monkeypatch):
"""GET /api/jobs returns normalized job rows."""
now = datetime.now(UTC)
job = SimpleNamespace(
id=uuid4(),
document_id=uuid4(),
status=SimpleNamespace(value="queued"),
created_at=now,
updated_at=now,
)
monkeypatch.setattr("transcription.api.routes.list_jobs", lambda: [job])
client = TestClient(_build_app())
response = client.get("/api/jobs")
assert response.status_code == 200
payload = response.json()
assert len(payload) == 1
assert payload[0]["id"] == str(job.id)
assert payload[0]["status"] == "queued"
def test_create_revision_returns_revision_payload(self, monkeypatch):
"""POST /api/jobs/{job_id}/revisions returns created revision fields."""
revision = SimpleNamespace(
id=uuid4(),
job_id=uuid4(),
revision_number=2,
text="edited text",
source="user",
accepted=False,
created_at=datetime.now(UTC),
)
monkeypatch.setattr("transcription.api.routes.add_revision", lambda **_kwargs: revision)
client = TestClient(_build_app())
response = client.post(
f"/api/jobs/{revision.job_id}/revisions",
json={"text": "edited text", "source": "user", "accepted": False},
)
assert response.status_code == 200
payload = response.json()
assert payload["id"] == str(revision.id)
assert payload["revision_number"] == 2
assert payload["text"] == "edited text"
def test_search_returns_results(self, monkeypatch):
"""GET /api/search returns accepted transcript matches."""
result = SimpleNamespace(
id=uuid4(),
job_id=uuid4(),
revision_number=1,
text="family archive",
source="user",
accepted=True,
created_at=datetime.now(UTC),
)
monkeypatch.setattr("transcription.api.routes.search_accepted_transcripts", lambda query: [result])
client = TestClient(_build_app())
response = client.get("/api/search", params={"query": "archive"})
assert response.status_code == 200
payload = response.json()
assert len(payload) == 1
assert payload[0]["revision_id"] == str(result.id)
assert payload[0]["accepted"] is True
def test_export_returns_count_and_records(self, monkeypatch):
"""GET /api/export returns record count and payload list."""
records = [
{
"job_id": str(uuid4()),
"document_id": str(uuid4()),
"filename": "letter.jpg",
"revision_id": str(uuid4()),
"revision_number": 1,
"accepted": True,
"source": "user",
"text": "exported",
"created_at": datetime.now(UTC).isoformat(),
}
]
monkeypatch.setattr("transcription.api.routes.export_transcripts", lambda accepted_only=True: records)
client = TestClient(_build_app())
response = client.get("/api/export")
assert response.status_code == 200
payload = response.json()
assert payload["count"] == 1
assert payload["accepted_only"] is True
assert payload["records"] == records
@@ -0,0 +1,42 @@
source: Book Two - page 02.jpg
provider: openrouter
model: google/gemini-2.5-flash
---
BY WAY OF INTRODUCTION:-
These few paragraphs of introduction may help you read BOOK 2 which covers a
wider range than did BOOK 1 (Pioneer Days).
BOOK 1 had 54 pages; 14 chapters. BOOK 2 has 70 pages; 18 chapters. BOOK 1 con-
sisted largely of first generation family history. BOOK 2 throws more light on
the second generation. Sidney promises a BOOK 3 and that may begin to do justice
to the third generation. We suggest that Sidney get the help of Louis Shinn
who has a chapter in this book (Chapter 16 - The Last 25 Years on the Doumecq
Plains. Louis has the gift of seeing, recalling and telling. One sentence in
his chapter gives a great tribute to the Doumecqers--so far as he knows no one
on the Doumecq Plains went on relief during the depression. That in a nutshell
shows the sturdy character of the residents of the Doumecq Plains.
We promised in BOOK 1 that in BOOK 2 we would give the story of the trip of
John E. Cochran and wife to Tennessee, Cuba and the Panama Canal. You will see
by the Table of Contents that the first four chapters have been given to those
trips. Those chapters are worth reading and re-reading. Mr. Cochran has eyes
to see and a pen to tell. We think the people in Tennessee will read with
great pleasure the comments he makes on conditions today.
Some who get this book will consider the group picture the best thing in the
book. It took a lot of preliminary photographing to reduce some pictures, enlarge
others and bring out the tin types. We wish that instead of 44 faces we could
have given 88. Do not blame Ethel Cochran-Shinn for the selection. She furnished
enough pictures but we had to take only part of them. We think there are great
possibilities in reproducing old pictures. We wish we had a Pickard group. Some
Pickard descendant may wish to make a collection.
We are much impressed with the future possibilities of getting a complete geneol-[sic]
ogy of the Pickard family. Mr. Cochran has a fine chapter on the Pickards but
to date we have not had the pleasure of finding all of the family dates. We had
intended to give more family data in this book but it takes time to get the
correct dates. Often times it requires trips to cemeteries to get dates on the
tombstones. Winter is no time to collect dates on tombstones.
-2-
@@ -0,0 +1,113 @@
source: Omie Writes Home.pdf
provider: openrouter
model: google/gemini-2.5-flash
---
JOHN E. COCHRAN
FAMILY ASSOCIATION
Family Only
Home | Sibling's Stories | 1st Cousins | Ancestor's Stories | Reunion History | Next Reunion | JECMEF
OMIE WRITES HOME
Ed. Note: The following letter was written by Omie Cochran in Nome, Alaska and sent to her
sister, Ethel Shinn, in Canfield, Idaho in 1923. It has been stored away these 63 years in the
original envelope with its 2 cent stamp. The letter has a number of references to the Shinn
children. Peter was Louis; Polly was Edith; and the 'little black rascal' referred to Maurice.
Miss Saville was the nurse at the Nome Hospital that was mentioned in the article by Inez in
the family newsletter two years ago.
Nome Alaska August 26, 1923
My Dear Ethel et al.
I don't know when I did write or when you did
but I am going to write now however and never
the less. But I wish I could talk (I can yet but I
mean to tell you all) instead and see ole Unc Pete
and Polly sit up and listen and that little black
rascal of yours would fairly sparkle with
listening. Can't I see him listening now to all the
yarns we told last summer?
[photo of people on ice with kayak and dog sled]
You see, we-Miss Saville and I, took a trip north
on the Buford and it was very interesting. We
went north thru the Bering Strait into the Arctic and as far as the Ice Pack. There the captain
of our craft and some other mighty hunters went out first in kayaks and later in row boats and
shot seven walrus. When they also took a movie man and camera, so you will likely see all
this in the movies before I get to tell you. They came back on board and the ship went up
along the icebergs and the walrus were 'heisted' on board by the use of the crane which loads
tons of freight and the beasts were so huge that they made the pulleys just creak. They were
over 12 feet long, as big around as three cows, had no feet but toenails on their flippers or
flappers, no head but their body just suddenly ended with a hole for a mouth and big bristles
all around it similar to a currycomb in coarseness; no ears but huge tusks of ivory. They are
the most repulsive looking animals imaginable and tho I have always read about them I never
expect such disagreeable looking creatures. They had a rough brown hairy skin and some of
them looked warty. They must have weighed two ton at least. Ere we got them back to Nome
to the natives they were getting extremely odiferous—in fact, you could scarcely stay on the
ship with any degree of comfort unless you had per chance lost your sense of smell.
Then we went north to a few minutes beyond the 70th degree of latitude and thot for awhile
we would go to Wrangell Island where some men from Steffonsons ship were supposed to be
stranded but we didn't get there and instead stopped at a small native village at Cape Serdz in
Siberia. These Eskimo were very primitive. One white squaw man lived there and had for 23
years. He was a Swede--who else could. Their houses were circular and built up with dirt 2 or
3 feet and then skins were stretched over it and weighted down with rocks. Inside, the room
was partitioned off at the sides with skins for sleeping quarters. In the main part they had the
fire on the ground and the fish drying on lines and the skins hanging around and the dogs and
babies and children. They wore skin clothes entirely. The women's were made like bloomers
and were heavily padded for warmth. They wore high mukluks and really looked very
comfortable. The babies were in fur skins with the fur inside and they looked like little Teddy
bears with faces. I guess they had never seen white women, not so many at one time anyway.
We went ashore in 2 life boats and a launch pulling them. On the launch was a six piece band
playing and the rear of the last life boat was the movie man. 'Twas very thrilling.
The other place we stopped was at Whalen, a trading post in Siberia. There these 'towerists'
went wild. They rushed helter-skelter, hither and thither, here and there, trying to find
something to buy. Prices raised right before your eyes. One would but something for $1.00
and the next might have to pay $2.00, $4.00 or $10.00. That made no difference. They had to
have it. One man I was sort of taking care of, tho he had his son along for the purpose,
bought 2 ivory tusks, 1 pup, 2 moccasins, 3 or 4 billikens, 6 or 8 ivory and silver rings, one
fishing line, hooks, floats, etc. and two bird slings. The slings have rocks at the end and the
little natives throw them at the flocks of geese and ducks which fly close over the village and
the slings entangle their wings and legs, sometimes more than one, and they can't fly. They
come down and the natives capture them. There was more junk brot aboard than baggage, I
do believe. And they say that at the first stop it was worse than here. The red flag was flying
over Whalen and the Russian soldiers were there—a few, one or two or three, I forget the
number.
We got home yesterday morning at 5 a.m. but missed the first lighter in so had to stay out
until 2:30. The girls had prepared a big meal for us and invited up the Hartfords and then let
us talk. Miss Saville talked quite a bit. Any how if you folks don't like this I don't care, it is
all I had to write about and I know Buster'ud listen anyway and I'd soak ole Peter's head if he
didn't and Polly would in my lap and I don't know much about the youngest one of yours so
likely he would be squawling. But we did surely enjoy our trip and were gone just long enuf.
I expect there were 150 passengers on board and almost or more of the crew and helpers. We
had a stateroom down next to the kitchen and 'twas pretty fierce for odor at times.
I have had jobs nearly all summer but not very much in them. Next week, September 4,
school opens. I wish they would wait for a week but you know these school men. Wouldn't
make any special difference I suppose for I would just fritter away the time but still one likes
to postpone the inevitable.
I just now stopped and re-read your letter and it sure was a bird. I don't especially blame Ray
for reading over your shoulder. It would seem, then that you have bright children. Maybe
they do know something about Geography. But it is ridiculous to speak of Louis finishing the
eighth grade. Why you and I were grown children when we finished and he is only a baby. I
am rather afraid he doesn't know much. I quite remember your little timid Maurice and how
he shifted his affection from his Aunt Om and yelled and howled and screamed steadily for a
week while his mother went to S.S. Ask him is he recalls this little interview. Do you suppose
he does?
Ever hear from Zen? or Inez? They don't seem to be very writing inclined tho Zen has done
well this year. Even sent me a telegram a few weeks ago. Well, if anything else ever happens,
I'll write again. Don't suppose it ever will, tho.
Lots of love to all,
Ome
Reprinted from Cochran Chronicles, Volume 9, Number 1, November 1986
© JECFA 1986
Up
jecochranclan.org ~ Contact webmaster
@@ -0,0 +1,29 @@
source: Rod Moser Letter - p1.jpg
provider: openrouter
model: google/gemini-2.5-flash
---
JOHN ISBILL R. T. MOSER
ISBILL & MOSER
DEALERS IN
GENERAL MERCHANDISE
Vonore, Tenn. January 27 - 1913
Dear Uncle [sic] Aun[t Adeline?]
Was at home a
few nights ago & saw a
letter from you folks, so
I decided to write you
a few lines myself &
I am contemplating a
trip out west next summ[er]
& I want both of fillers [sic] to go
when I am [to] them.
Am getting
up in years & unmarried,
so you see the object of
my trip, is to get a wife
& I hear is a lot old maids
& widows out there. I
want you to see them
at my land my [sic] at there [sic]
as soon as I get there
+24
View File
@@ -0,0 +1,24 @@
"""Shared test fixtures.
Every test gets a fresh in-memory SQLite database so tests are
isolated, fast, and leave no artifacts on disk.
"""
import pytest
from sqlmodel import Session
from sqlmodel import SQLModel
from sqlmodel import create_engine
from sqlmodel.pool import StaticPool
@pytest.fixture
def session():
"""Provide a clean database session for each test."""
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
SQLModel.metadata.create_all(engine)
with Session(engine) as session:
yield session
View File
+1
View File
@@ -0,0 +1 @@
not an image fixture
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1010 KiB

Some files were not shown because too many files have changed in this diff Show More