cmuchancel commited on
Commit
c558db6
·
verified ·
1 Parent(s): 3117306

Require method selection and show ChatGPT sign-in in a modal

Browse files
Files changed (7) hide show
  1. ARCHITECTURE.md +1 -1
  2. README.md +2 -2
  3. agent_app.py +5 -39
  4. styles.css +63 -17
  5. tests/test_ui_workflow.py +99 -0
  6. ui_dialog.js +61 -0
  7. ui_workflow.py +156 -0
ARCHITECTURE.md CHANGED
@@ -62,7 +62,7 @@ The host clears this run's patent collection on success, failure or cancellation
62
 
63
  SysML export is deterministic: nested actions for the function hierarchy and directed item flows with generic `FlowItem` payloads. Claims/interfaces remain documentation, not formal requirements or complete physical simulation semantics. The pinned SysIDE Legacy 0.9.1 language server checks the generated file against its 2024-12 grammar/library. This is a useful parser and semantic check, not certification against the final SysML 2.0 standard. Parser errors or unavailable validation prevent a successful generated export.
64
 
65
- The three Gradio tabs remain Diagram, JSON text, and SysML download. A research ZIP download is inside the JSON tab. The uploader accepts patent HTML (.html or .htm) only. JSON is available as an output download and cannot be uploaded as input.
66
 
67
  ## Research record and persistence
68
 
 
62
 
63
  SysML export is deterministic: nested actions for the function hierarchy and directed item flows with generic `FlowItem` payloads. Claims/interfaces remain documentation, not formal requirements or complete physical simulation semantics. The pinned SysIDE Legacy 0.9.1 language server checks the generated file against its 2024-12 grammar/library. This is a useful parser and semantic check, not certification against the final SysML 2.0 standard. Parser errors or unavailable validation prevent a successful generated export.
64
 
65
+ The Gradio interface reveals methods after upload and requires an explicit selection before exposing Process. AI agents opens a session-specific sign-in popup, which closes after connection. Results are revealed after processing; the three tabs remain Diagram, JSON text, and SysML download. A research ZIP download is inside the JSON tab. The uploader accepts patent HTML (.html or .htm) only. JSON is available as an output download and cannot be uploaded as input.
66
 
67
  ## Research record and persistence
68
 
README.md CHANGED
@@ -16,7 +16,7 @@ short_description: Turn patent HTML into diagrams, JSON, and SysML
16
 
17
  [Open the app](https://huggingface.co/spaces/cmuchancel/patent2sysml) · [Workflow](ARCHITECTURE.md) · [Research record](RESEARCH_RECORD.md)
18
 
19
- A simple Gradio interface for your teammate's [Info-extraction](https://github.com/eandujar09/Info-extraction) agents, pinned at `848d0337cce01bebc620da4a2b3d873c16ffb326`. Upload patent HTML, connect ChatGPT, then click **Process**. The three output tabs show a diagram, JSON, and a downloadable SysML file. Fine Tuned NLP remains disabled.
20
 
21
  The pipeline keeps the five fixed textbook queries, adds patent-specific questions, drafts a model, independently reviews it, and allows one repair pass followed by re-review. Unsupported unresolved items and dependent connections are omitted from the final model. Full findings, drafts and removal explanations stay in the private research archive. The JSON tab also downloads that complete ZIP.
22
 
@@ -46,7 +46,7 @@ The parser is pinned to open-source SysIDE Legacy 0.9.1 and its 2024-12 library.
46
 
47
  | File | Purpose |
48
  | --- | --- |
49
- | `agent_app.py`, `styles.css` | Simple Gradio layout and styling. |
50
  | `workflow.py` | Fixed questions, new agent instructions, retrieval budgets. |
51
  | `agent_runner.py` | Configure/launch agents, enforce completion, cleanup and exports. |
52
  | `workspace_mcp.py`, `quality.py` | Draft/review/repair gates and final omission rules. |
 
16
 
17
  [Open the app](https://huggingface.co/spaces/cmuchancel/patent2sysml) · [Workflow](ARCHITECTURE.md) · [Research record](RESEARCH_RECORD.md)
18
 
19
+ A simple Gradio interface for your teammate's [Info-extraction](https://github.com/eandujar09/Info-extraction) agents, pinned at `848d0337cce01bebc620da4a2b3d873c16ffb326`. Upload patent HTML, choose **AI agents**, connect ChatGPT in the sign-in popup, then click **Process**. No method is selected automatically. The popup closes on successful sign-in, and output tabs appear after processing. The three output tabs show a diagram, JSON, and a downloadable SysML file. Fine Tuned NLP remains disabled.
20
 
21
  The pipeline keeps the five fixed textbook queries, adds patent-specific questions, drafts a model, independently reviews it, and allows one repair pass followed by re-review. Unsupported unresolved items and dependent connections are omitted from the final model. Full findings, drafts and removal explanations stay in the private research archive. The JSON tab also downloads that complete ZIP.
22
 
 
46
 
47
  | File | Purpose |
48
  | --- | --- |
49
+ | `agent_app.py`, `ui_workflow.py`, `ui_dialog.js`, `styles.css` | Gradio processing, progressive controls, accessible sign-in popup, and styling. |
50
  | `workflow.py` | Fixed questions, new agent instructions, retrieval budgets. |
51
  | `agent_runner.py` | Configure/launch agents, enforce completion, cleanup and exports. |
52
  | `workspace_mcp.py`, `quality.py` | Draft/review/repair gates and final omission rules. |
agent_app.py CHANGED
@@ -4,14 +4,13 @@ import os
4
  import tempfile
5
  from pathlib import Path
6
 
7
- import gradio as gr
8
  from rendering import diagram
9
 
10
  from agent_runner import run_agents
11
- from chatgpt_login import connect_chatgpt
12
  from sysml_export import to_sysml
13
  from textbook_index import load_index
14
- from user_session import SESSIONS, new_session, delete_session
 
15
 
16
  load_index()
17
 
@@ -50,43 +49,10 @@ def process(file: str | None, session=None):
50
  yield str(error), "", None, "", "", None, None
51
 
52
 
53
- with gr.Blocks(title="Translate patents to SysML v2") as app:
54
- session = gr.State(new_session if os.getenv("SPACE_ID") else None,
55
- time_to_live=3600, delete_callback=delete_session)
56
- if os.getenv("PATENT_SPACE_WRAPPER"):
57
- gr.Button(visible=False).click(unused_gpu_slot, api_name=False)
58
- gr.Markdown("# Translate patents to SysML v2\nUpload a patent. Generate a system model.")
59
- if os.getenv("SPACE_ID"):
60
- with gr.Accordion("Connect ChatGPT", open=True):
61
- gr.Markdown("Sign in with your own ChatGPT account for this session. "
62
- "Your login is separate from other visitors and expires when the session is cleaned up.")
63
- connect = gr.Button("Connect ChatGPT")
64
- login_status = gr.Markdown()
65
- connect.click(connect_chatgpt, inputs=session, outputs=login_status,
66
- api_name=False, concurrency_limit=4)
67
- patent = gr.File(label="Patent HTML · .html or .htm · up to 10 MB", file_types=[".html", ".htm"], type="filepath")
68
- with gr.Row():
69
- agents = gr.Button("AI agents", variant="primary")
70
- gr.Button("Fine Tuned NLP", interactive=False)
71
- gr.Markdown("Research mode: uploaded patents, agent conversations, retrieved evidence, and outputs "
72
- "are retained in the project owner’s private research archive. Login credentials are excluded.")
73
- start = gr.Button("Process", variant="primary")
74
- status = gr.Markdown("Ready. Each run uses your connected OpenCode model.")
75
- agents.click(lambda: "AI agents selected.", outputs=status, api_name=False)
76
- with gr.Tab("Diagram"):
77
- preview = gr.HTML(elem_id="diagram")
78
- with gr.Tab("JSON text"):
79
- result = gr.Code(label="Functional-decomposition JSON", language="json", interactive=False)
80
- download = gr.DownloadButton("Download JSON")
81
- research_download = gr.DownloadButton("Download complete research record (.zip)")
82
- with gr.Tab("SysML download"):
83
- sysml_download = gr.DownloadButton("Download .sysml")
84
- sysml_text = gr.Code(label="SysML v2", language=None, interactive=False)
85
- gr.Markdown("Uses the Info-extraction agents. SJS conversion is a separate teammate integration.")
86
- start.click(process, [patent, session], [status, result, download, preview, sysml_text, sysml_download, research_download],
87
- api_name="process_patent", concurrency_limit=1)
88
 
89
  if __name__ == "__main__":
90
  app.queue(max_size=5).launch(server_name=os.getenv("GRADIO_SERVER_NAME", "0.0.0.0" if os.getenv("SPACE_ID") else "127.0.0.1"),
91
- css_paths=Path(__file__).with_name("styles.css"), max_file_size="10mb",
 
92
  blocked_paths=[str(SESSIONS), str(Path(__file__).parent / "assets")])
 
4
  import tempfile
5
  from pathlib import Path
6
 
 
7
  from rendering import diagram
8
 
9
  from agent_runner import run_agents
 
10
  from sysml_export import to_sysml
11
  from textbook_index import load_index
12
+ from user_session import SESSIONS
13
+ from ui_workflow import build_app
14
 
15
  load_index()
16
 
 
49
  yield str(error), "", None, "", "", None, None
50
 
51
 
52
+ app = build_app(process, globals().get("unused_gpu_slot"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
 
54
  if __name__ == "__main__":
55
  app.queue(max_size=5).launch(server_name=os.getenv("GRADIO_SERVER_NAME", "0.0.0.0" if os.getenv("SPACE_ID") else "127.0.0.1"),
56
+ css_paths=Path(__file__).with_name("styles.css"),
57
+ js=Path(__file__).with_name("ui_dialog.js").read_text(), max_file_size="10mb",
58
  blocked_paths=[str(SESSIONS), str(Path(__file__).parent / "assets")])
styles.css CHANGED
@@ -1,4 +1,4 @@
1
- /* A CSS-only visual layer over the existing Gradio controls. */
2
  :root {
3
  color-scheme: light;
4
  --patent-bg: #f8fafc;
@@ -127,10 +127,9 @@ body, gradio-app { background: var(--patent-bg) !important; }
127
  border-style: solid !important;
128
  border-color: #ced9f3 !important;
129
  }
130
- /* Initially show just the heading and native upload area. */
131
- .gradio-container main > .column:has([data-testid="upload-text"]) > :not(.block:has(h1), .block:has([data-testid="upload-text"])) { display: none !important; }
132
- .gradio-container main > .column > .row:has(> button.primary) { gap: 15px; margin-top: 5px; }
133
- .gradio-container main > .column > .row > button {
134
  display: flex;
135
  flex-direction: column;
136
  align-items: flex-start;
@@ -147,34 +146,34 @@ body, gradio-app { background: var(--patent-bg) !important; }
147
  box-shadow: none;
148
  transition: background .2s, border-color .2s;
149
  }
150
- .gradio-container main > .column > .row > button.primary {
151
  background: #f2f5ff !important;
152
  border: 1px solid #a4b8f2 !important;
153
  color: #233b78 !important;
154
  }
155
- .gradio-container main > .column > .row > button.primary::after {
156
  content: 'Use your connected ChatGPT account';
157
  font-size: 12px;
158
  font-weight: 400;
159
  letter-spacing: 0;
160
  color: #5c6f99;
161
  }
162
- .gradio-container main > .column > .row > button.primary:hover { background: #eaf0ff !important; border-color: #6483e6 !important; }
163
- .gradio-container main > .column > .row > button:disabled {
164
  opacity: 1 !important;
165
  background: #f5f7fa !important;
166
  border: 1px solid #e1e6ed !important;
167
  color: #6d788b !important;
168
  cursor: not-allowed;
169
  }
170
- .gradio-container main > .column > .row > button:disabled::after {
171
  content: 'Coming soon';
172
  color: #737f90;
173
  font-size: 12px;
174
  font-weight: 400;
175
  letter-spacing: 0;
176
  }
177
- .gradio-container main > .column > button.primary {
178
  min-height: 52px;
179
  border-radius: 11px;
180
  border: 1px solid #315de8 !important;
@@ -185,11 +184,11 @@ body, gradio-app { background: var(--patent-bg) !important; }
185
  box-shadow: 0 4px 10px #315de81a;
186
  transition: background .2s;
187
  }
188
- .gradio-container main > .column > button.primary::after { content: ' →'; padding-left: 7px; }
189
- .gradio-container main > .column > button.primary:hover { background: #244bcc !important; }
190
- .gradio-container main > .column > .row + .block .prose p { color: #718096; font-size: 11px; line-height: 1.75; }
191
- .gradio-container main > .column > button.primary + .block { padding: 6px 0 6px 12px !important; border-left: 2px solid #c9d6f8 !important; min-height: 34px; }
192
- .gradio-container main > .column > button.primary + .block .prose p { color: #5b6980; font-size: 12px; line-height: 1.7; }
193
  .gradio-container .block:has(> .label) { border-radius: 14px !important; border-color: #dce3ed !important; background: #fff !important; }
194
  .gradio-container .block:has(> .label) .prose p { color: #66748b; font-size: 13px; line-height: 1.7; }
195
  .gradio-container .prose a { color: #315de8 !important; text-underline-offset: 4px; }
@@ -229,7 +228,7 @@ body, gradio-app { background: var(--patent-bg) !important; }
229
  .prose h1 + p { font-size: 13px !important; }
230
  .upload-container { height: 265px !important; min-height: 265px; }
231
  .block:has(> [data-testid="block-label"]) { min-height: 265px; }
232
- .row:has(> button.primary) { flex-direction: column; }
233
  .row > button { width: 100%; min-height: 115px; padding: 22px; }
234
  .tabs { padding: 6px 12px 12px; }
235
  [role="tablist"] { gap: 9px; }
@@ -240,3 +239,50 @@ body, gradio-app { background: var(--patent-bg) !important; }
240
  *, *::before, *::after { animation: none !important; transition: none !important; }
241
  .patent-progress[aria-busy="true"] .patent-progress__fill { width: 100%; opacity: .6; }
242
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Light styling for the native Gradio upload, method, sign-in and result controls. */
2
  :root {
3
  color-scheme: light;
4
  --patent-bg: #f8fafc;
 
127
  border-style: solid !important;
128
  border-color: #ced9f3 !important;
129
  }
130
+ /* Visibility is controlled by Gradio events, not CSS guesses about upload state. */
131
+ .gradio-container #method-options { gap: 15px; margin-top: 5px; }
132
+ .gradio-container #method-options > button {
 
133
  display: flex;
134
  flex-direction: column;
135
  align-items: flex-start;
 
146
  box-shadow: none;
147
  transition: background .2s, border-color .2s;
148
  }
149
+ .gradio-container #method-agents.primary {
150
  background: #f2f5ff !important;
151
  border: 1px solid #a4b8f2 !important;
152
  color: #233b78 !important;
153
  }
154
+ .gradio-container #method-agents::after {
155
  content: 'Use your connected ChatGPT account';
156
  font-size: 12px;
157
  font-weight: 400;
158
  letter-spacing: 0;
159
  color: #5c6f99;
160
  }
161
+ .gradio-container #method-agents.primary:hover { background: #eaf0ff !important; border-color: #6483e6 !important; }
162
+ .gradio-container #method-nlp {
163
  opacity: 1 !important;
164
  background: #f5f7fa !important;
165
  border: 1px solid #e1e6ed !important;
166
  color: #6d788b !important;
167
  cursor: not-allowed;
168
  }
169
+ .gradio-container #method-nlp::after {
170
  content: 'Coming soon';
171
  color: #737f90;
172
  font-size: 12px;
173
  font-weight: 400;
174
  letter-spacing: 0;
175
  }
176
+ .gradio-container #process-button {
177
  min-height: 52px;
178
  border-radius: 11px;
179
  border: 1px solid #315de8 !important;
 
184
  box-shadow: 0 4px 10px #315de81a;
185
  transition: background .2s;
186
  }
187
+ .gradio-container #process-button::after { content: ' →'; padding-left: 7px; }
188
+ .gradio-container #process-button:hover { background: #244bcc !important; }
189
+ .gradio-container #research-disclosure .prose p { color: #718096; font-size: 11px; line-height: 1.75; }
190
+ .gradio-container #process-status { padding: 6px 0 6px 12px !important; border-left: 2px solid #c9d6f8 !important; min-height: 34px; }
191
+ .gradio-container #process-status .prose p { color: #5b6980; font-size: 12px; line-height: 1.7; }
192
  .gradio-container .block:has(> .label) { border-radius: 14px !important; border-color: #dce3ed !important; background: #fff !important; }
193
  .gradio-container .block:has(> .label) .prose p { color: #66748b; font-size: 13px; line-height: 1.7; }
194
  .gradio-container .prose a { color: #315de8 !important; text-underline-offset: 4px; }
 
228
  .prose h1 + p { font-size: 13px !important; }
229
  .upload-container { height: 265px !important; min-height: 265px; }
230
  .block:has(> [data-testid="block-label"]) { min-height: 265px; }
231
+ #method-options { flex-direction: column; }
232
  .row > button { width: 100%; min-height: 115px; padding: 22px; }
233
  .tabs { padding: 6px 12px 12px; }
234
  [role="tablist"] { gap: 9px; }
 
239
  *, *::before, *::after { animation: none !important; transition: none !important; }
240
  .patent-progress[aria-busy="true"] .patent-progress__fill { width: 100%; opacity: .6; }
241
  }
242
+
243
+ /* Explicit neutral method state; only the selected card uses the blue treatment. */
244
+ .gradio-container #method-agents.secondary {
245
+ background: #fff !important; border: 1px solid #d9e1ec !important; color: #263346 !important;
246
+ }
247
+ .gradio-container #method-agents.secondary:hover { border-color: #7896e9 !important; background: #f8faff !important; }
248
+ .gradio-container #process-button:disabled { opacity: 1; background: #e7ecf5 !important; border-color: #e0e6ef !important; color: #6e7c92 !important; box-shadow: none; }
249
+ .gradio-container #reconnect-chatgpt { width: auto; align-self: flex-start; min-height: 40px; padding: 9px 16px; font-size: 13px; color: #315de8 !important; background: #f0f4ff !important; border: 1px solid #d5dff8 !important; }
250
+ /* Override table colors explicitly, including when the visitor prefers dark mode. */
251
+ .gradio-container #patent-upload:has(.file-preview-holder) { padding: 19px 20px 17px !important; }
252
+ .gradio-container #patent-upload:has(.file-preview-holder) > [data-testid="block-label"] { position: static !important; margin: 0 30px 13px 0 !important; }
253
+ .gradio-container #patent-upload .file-preview-holder { background: transparent !important; padding: 0 !important; border: 0 !important; }
254
+ .gradio-container #patent-upload :is(table, tbody, tr, td) { background: #f2f5fb !important; color: #263346 !important; border: 0 !important; }
255
+ .gradio-container #patent-upload table { border-collapse: separate; border-spacing: 0; border-radius: 11px; overflow: hidden; }
256
+ .gradio-container #patent-upload td { padding: 17px 15px !important; font-size: 13px; }
257
+ .gradio-container #patent-upload :is(.filename, .stem, .ext) { color: #263346 !important; }
258
+ .gradio-container #patent-upload .download-link { color: #315de8 !important; font-size: 12px; white-space: nowrap; }
259
+ .gradio-container #patent-upload button[aria-label="Clear"] { background: #fff !important; color: #64748b !important; border-radius: 9px; }
260
+ /* Gradio Column overlay; it is absent until the user chooses AI agents. */
261
+ .gradio-container #login-modal {
262
+ position: fixed !important; inset: 0 !important; z-index: 10000 !important;
263
+ width: 100vw !important; height: 100dvh !important; max-width: none !important;
264
+ min-width: 0 !important; margin: 0 !important; padding: 24px !important;
265
+ align-items: center; justify-content: center; overflow-y: auto;
266
+ background: #17203366 !important; backdrop-filter: blur(7px);
267
+ }
268
+ .gradio-container #login-card {
269
+ position: relative; flex: 0 0 auto; width: min(440px, 100%); min-width: 0 !important;
270
+ gap: 19px; padding: 38px 30px 25px !important; border: 1px solid #dce4ef;
271
+ border-radius: 20px; background: #fff !important; color: #17202d !important;
272
+ box-shadow: 0 24px 100px #16233c30;
273
+ }
274
+ .gradio-container #login-card h2 { color: #17202d !important; font-size: 27px !important; font-weight: 600 !important; line-height: 1.2; letter-spacing: -.8px; margin: 9px 0 15px !important; }
275
+ .gradio-container #login-description { color: #5c6a80 !important; font-size: 14px; line-height: 1.7; }
276
+ .gradio-container #close-login { position: absolute; top: 12px; right: 12px; z-index: 1; width: 34px; min-width: 34px; min-height: 34px; padding: 0; border: 0; background: #f3f6fa !important; color: #526178 !important; }
277
+ .gradio-container #login-status { padding: 16px !important; border: 1px solid #dce5f6 !important; border-radius: 11px; background: #f4f7fd !important; }
278
+ .gradio-container #login-status .prose { color: #34445e !important; font-size: 13px; line-height: 1.75; }
279
+ .gradio-container #login-status strong { color: #17202d !important; }
280
+ .gradio-container #connect-chatgpt { min-height: 48px; background: #315de8 !important; color: #fff !important; border: 1px solid #315de8 !important; border-radius: 10px; font-size: 14px; }
281
+ .gradio-container #connect-chatgpt:disabled { background: #e7ecf5 !important; color: #526178 !important; border-color: #dbe3ef !important; opacity: 1; }
282
+ .gradio-container #login-note p { color: #6c7a8e !important; font-size: 11px; line-height: 1.6; }
283
+ @media (max-width: 640px) {
284
+ #login-modal { padding: 16px !important; }
285
+ #login-card { padding: 36px 22px 23px !important; }
286
+ #patent-upload:has(.file-preview-holder) { min-height: 0 !important; padding: 17px 13px !important; }
287
+ #patent-upload td { padding: 15px 10px !important; }
288
+ }
tests/test_ui_workflow.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Regression checks for progressive controls and login lifecycle, without model calls."""
2
+ import sys
3
+ import unittest
4
+ from pathlib import Path
5
+ from unittest.mock import patch
6
+
7
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
8
+ from ui_workflow import build_app
9
+
10
+
11
+ def example_process(file, session):
12
+ yield 'Working', '', None, '', '', None, None
13
+ yield 'Complete', '{"views": {}}', '/tmp/result.json', '<svg/>', 'package Example {}', '/tmp/result.sysml', '/tmp/research.zip'
14
+
15
+
16
+ class InterfaceTests(unittest.TestCase):
17
+ @classmethod
18
+ def setUpClass(cls):
19
+ cls.app = build_app(example_process)
20
+ cls.handlers = {f.fn.__name__: f.fn for f in cls.app.fns.values() if f.fn}
21
+ cls.components = {c.elem_id: c for c in cls.app.blocks.values() if getattr(c, 'elem_id', None)}
22
+
23
+ def component(self, name):
24
+ return self.components[name]
25
+
26
+ def test_initial_state_has_no_method_login_process_or_results(self):
27
+ for name in ['method-options', 'login-modal', 'process-button', 'results-section']:
28
+ self.assertFalse(self.component(name).visible)
29
+ self.assertEqual(self.component('method-agents').variant, 'secondary')
30
+
31
+ def test_upload_requires_fresh_selection_and_clears_results(self):
32
+ updates = self.handlers['on_upload']('/tmp/patent.html')
33
+ self.assertTrue(updates[self.component('method-options')]['visible'])
34
+ for name in ['process-button', 'login-modal', 'results-section']:
35
+ self.assertFalse(updates[self.component(name)]['visible'])
36
+ self.assertEqual(updates[self.component('method-agents')]['variant'], 'secondary')
37
+ self.assertFalse(self.handlers['on_upload'](None)[self.component('method-options')]['visible'])
38
+
39
+ @patch('ui_workflow.login_needed', return_value=True)
40
+ def test_explicit_selection_opens_login_and_disables_process(self, _):
41
+ updates = self.handlers['choose_agents']('/tmp/patent.html', 'session')
42
+ self.assertTrue(updates[self.component('login-modal')]['visible'])
43
+ self.assertTrue(updates[self.component('process-button')]['visible'])
44
+ self.assertFalse(updates[self.component('process-button')]['interactive'])
45
+
46
+ @patch('ui_workflow.login_needed', return_value=False)
47
+ def test_connected_user_can_choose_method_without_another_login(self, _):
48
+ updates = self.handlers['choose_agents']('/tmp/patent.html', 'session')
49
+ self.assertFalse(updates[self.component('login-modal')]['visible'])
50
+ self.assertTrue(updates[self.component('process-button')]['interactive'])
51
+
52
+ def test_successful_login_closes_dialog_and_enables_process(self):
53
+ state = {'needs_login': True}
54
+ def login(visitor):
55
+ yield 'Enter your code'
56
+ state['needs_login'] = False
57
+ yield 'Connected'
58
+ with patch('ui_workflow.login_needed', side_effect=lambda _: state['needs_login']), patch('ui_workflow.connect_chatgpt', login):
59
+ updates = list(self.handlers['sign_in']('session'))
60
+ completed = next(x for x in updates if self.component('login-modal') in x)
61
+ self.assertFalse(completed[self.component('login-modal')]['visible'])
62
+ self.assertFalse(completed[self.component('reconnect-chatgpt')]['visible'])
63
+ self.assertTrue(completed[self.component('process-button')]['interactive'])
64
+
65
+ def test_cancel_closes_underlying_login_generator(self):
66
+ state = {'closed': False}
67
+ def login(visitor):
68
+ try:
69
+ yield 'Waiting for sign-in'
70
+ yield 'Still waiting'
71
+ finally:
72
+ state['closed'] = True
73
+ with patch('ui_workflow.login_needed', return_value=True), patch('ui_workflow.connect_chatgpt', login):
74
+ stream = self.handlers['sign_in']('session')
75
+ next(stream)
76
+ next(stream)
77
+ stream.close()
78
+ self.assertTrue(state['closed'])
79
+
80
+ @patch('ui_workflow.login_needed', return_value=True)
81
+ def test_failed_login_does_not_unlock_process(self, _):
82
+ def failed_login(visitor):
83
+ yield 'Sign-in expired'
84
+ with patch('ui_workflow.connect_chatgpt', failed_login):
85
+ updates = list(self.handlers['sign_in']('session'))
86
+ self.assertFalse(any(x.get(self.component('process-button'), {}).get('interactive') for x in updates))
87
+ self.assertTrue(updates[-1][self.component('connect-chatgpt')]['interactive'])
88
+
89
+ @patch('ui_workflow.login_needed', return_value=False)
90
+ def test_results_are_revealed_only_after_processing_finishes(self, _):
91
+ updates = list(self.handlers['run_ui']('/tmp/patent.html', 'agents', 'session'))
92
+ result_states = [u[self.component('results-section')]['visible'] for u in updates if self.component('results-section') in u]
93
+ self.assertEqual(result_states, [False, True])
94
+ self.assertTrue(updates[-1][self.component('patent-upload')]['interactive'])
95
+ self.assertTrue(updates[-1][self.component('process-button')]['interactive'])
96
+
97
+
98
+ if __name__ == '__main__':
99
+ unittest.main()
ui_dialog.js ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ () => {
2
+ window.__patentDialogCleanup?.();
3
+ let open = false;
4
+ let previousFocus;
5
+ let previousOverflow = '';
6
+ let inerted = [];
7
+ const visible = element => !!element?.getClientRects().length;
8
+ const sync = () => {
9
+ const modal = document.getElementById('login-modal');
10
+ const card = document.getElementById('login-card');
11
+ const close = document.getElementById('close-login');
12
+ if (card) {
13
+ card.setAttribute('role', 'dialog');
14
+ card.setAttribute('aria-modal', 'true');
15
+ card.setAttribute('aria-labelledby', 'login-title');
16
+ card.setAttribute('aria-describedby', 'login-description');
17
+ }
18
+ close?.setAttribute('aria-label', 'Close ChatGPT sign-in');
19
+ const agents = document.getElementById('method-agents');
20
+ agents?.setAttribute('aria-pressed', String(agents.classList.contains('primary')));
21
+ const next = visible(modal);
22
+ if (next === open) return;
23
+ open = next;
24
+ if (open) {
25
+ previousFocus = document.activeElement;
26
+ previousOverflow = document.body.style.overflow;
27
+ document.body.style.overflow = 'hidden';
28
+ inerted = [...(modal.parentElement?.children || [])]
29
+ .filter(el => el !== modal && !el.inert);
30
+ inerted.forEach(el => { el.inert = true; });
31
+ close?.focus();
32
+ } else {
33
+ document.body.style.overflow = previousOverflow;
34
+ inerted.forEach(el => { el.inert = false; });
35
+ inerted = [];
36
+ if (previousFocus?.isConnected) previousFocus.focus();
37
+ }
38
+ };
39
+ const onKey = event => {
40
+ if (!open) return;
41
+ if (event.key === 'Escape') {
42
+ event.preventDefault();
43
+ document.getElementById('close-login')?.click();
44
+ }
45
+ if (event.key !== 'Tab') return;
46
+ const controls = [...document.querySelectorAll('#login-card button:not(:disabled), #login-card a[href]')].filter(visible);
47
+ const first = controls[0], last = controls[controls.length - 1];
48
+ if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
49
+ else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
50
+ };
51
+ const observer = new MutationObserver(sync);
52
+ observer.observe(document.body, {childList: true, subtree: true, attributes: true, attributeFilter: ['class', 'style']});
53
+ document.addEventListener('keydown', onKey);
54
+ window.__patentDialogCleanup = () => {
55
+ observer.disconnect();
56
+ document.removeEventListener('keydown', onKey);
57
+ inerted.forEach(el => { el.inert = false; });
58
+ if (open) document.body.style.overflow = previousOverflow;
59
+ };
60
+ sync();
61
+ }
ui_workflow.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gradio presentation states for upload, explicit method choice, sign-in and results."""
2
+ from contextlib import closing
3
+ import logging
4
+ import os
5
+
6
+ import gradio as gr
7
+
8
+ from chatgpt_login import connect_chatgpt
9
+ from user_session import connected, delete_session, new_session
10
+
11
+
12
+ def login_needed(visitor):
13
+ if not os.getenv('SPACE_ID'):
14
+ return False
15
+ try:
16
+ return not connected(visitor)
17
+ except ValueError:
18
+ return True
19
+
20
+
21
+ def build_app(process, unused_gpu_slot=None):
22
+ with gr.Blocks(title='Translate patents to SysML v2') as app:
23
+ session = gr.State(new_session if os.getenv('SPACE_ID') else None,
24
+ time_to_live=3600, delete_callback=delete_session)
25
+ method = gr.State(None)
26
+ if unused_gpu_slot:
27
+ gr.Button(visible=False).click(unused_gpu_slot, api_name=False)
28
+ gr.Markdown('# Translate patents to SysML v2\nUpload a patent. Generate a system model.')
29
+ patent = gr.File(label='Patent HTML · .html or .htm · up to 10 MB',
30
+ file_types=['.html', '.htm'], type='filepath', elem_id='patent-upload')
31
+ with gr.Row(visible=False, elem_id='method-options') as methods:
32
+ agents = gr.Button('AI agents', variant='secondary', elem_id='method-agents')
33
+ gr.Button('Fine Tuned NLP', interactive=False, elem_id='method-nlp')
34
+ disclosure = gr.Markdown('Research mode: uploaded patents, agent conversations, retrieved evidence, and outputs '
35
+ 'are retained in the project owner’s private research archive. Login credentials are excluded.',
36
+ visible=False, elem_id='research-disclosure')
37
+ reconnect = gr.Button('Connect ChatGPT', visible=False, elem_id='reconnect-chatgpt')
38
+ start = gr.Button('Process', variant='primary', visible=False, interactive=False, elem_id='process-button')
39
+ status = gr.Markdown(visible=False, elem_id='process-status')
40
+ with gr.Column(visible=False, elem_id='results-section') as results:
41
+ with gr.Tab('Diagram'):
42
+ preview = gr.HTML(elem_id='diagram')
43
+ with gr.Tab('JSON text'):
44
+ result = gr.Code(label='Functional-decomposition JSON', language='json', interactive=False)
45
+ download = gr.DownloadButton('Download JSON')
46
+ research_download = gr.DownloadButton('Download complete research record (.zip)')
47
+ with gr.Tab('SysML download'):
48
+ sysml_download = gr.DownloadButton('Download .sysml')
49
+ sysml_text = gr.Code(label='SysML v2', language=None, interactive=False)
50
+
51
+ # Native Gradio components styled as a dialog; JS supplies focus trapping and Escape.
52
+ with gr.Column(visible=False, elem_id='login-modal') as modal:
53
+ with gr.Column(elem_id='login-card'):
54
+ close = gr.Button('✕', elem_id='close-login', min_width=36, scale=0, size='sm')
55
+ gr.HTML('<h2 id="login-title">Connect ChatGPT</h2>'
56
+ '<p id="login-description">Sign in with your ChatGPT account to use AI agents. '
57
+ 'Your connection is private to this session.</p>')
58
+ login_status = gr.Markdown('Continue to get a secure OpenAI sign-in link and code.', elem_id='login-status')
59
+ connect = gr.Button('Connect ChatGPT', variant='primary', elem_id='connect-chatgpt')
60
+ gr.Markdown('This window closes automatically once you’re connected.', elem_id='login-note')
61
+
62
+ output_components = [status, result, download, preview, sysml_text, sysml_download, research_download]
63
+
64
+ def on_upload(file):
65
+ return {methods: gr.update(visible=bool(file)), method: None,
66
+ agents: gr.update(variant='secondary'), disclosure: gr.update(visible=bool(file)),
67
+ start: gr.update(visible=False, interactive=False, value='Process'),
68
+ status: gr.update(visible=False, value=''), modal: gr.update(visible=False),
69
+ reconnect: gr.update(visible=False), connect: gr.update(interactive=True, value='Connect ChatGPT'),
70
+ results: gr.update(visible=False), result: '', download: None, preview: '',
71
+ sysml_text: '', sysml_download: None, research_download: None}
72
+
73
+ def choose_agents(file, visitor):
74
+ if not file:
75
+ raise gr.Error('Upload a patent HTML file first.')
76
+ needs_login = login_needed(visitor)
77
+ return {method: 'agents', agents: gr.update(variant='primary'),
78
+ modal: gr.update(visible=needs_login),
79
+ start: gr.update(visible=True, interactive=not needs_login),
80
+ reconnect: gr.update(visible=needs_login),
81
+ login_status: 'Continue to get a secure OpenAI sign-in link and code.',
82
+ connect: gr.update(interactive=True, value='Connect ChatGPT'),
83
+ status: gr.update(visible=True, value='AI agents selected. Connect ChatGPT to continue.' if needs_login
84
+ else 'AI agents selected. Ready to process.')}
85
+
86
+ def sign_in(visitor):
87
+ yield {connect: gr.update(interactive=False, value='Waiting for sign-in…')}
88
+ try:
89
+ if login_needed(visitor):
90
+ # Closing the dialog cancels this generator and the login subprocess.
91
+ with closing(connect_chatgpt(visitor)) as messages:
92
+ for message in messages:
93
+ yield {login_status: message}
94
+ if not login_needed(visitor):
95
+ yield {modal: gr.update(visible=False), reconnect: gr.update(visible=False),
96
+ start: gr.update(interactive=True),
97
+ status: gr.update(visible=True, value='ChatGPT connected. Ready to process.')}
98
+ else:
99
+ yield {start: gr.update(interactive=False)}
100
+ except (ValueError, OSError) as error:
101
+ yield {login_status: str(error), start: gr.update(interactive=False)}
102
+ yield {connect: gr.update(interactive=True, value='Connect ChatGPT')}
103
+
104
+ def dismiss_login():
105
+ return {modal: gr.update(visible=False), connect: gr.update(interactive=True, value='Connect ChatGPT')}
106
+
107
+ def run_ui(file, selected, visitor):
108
+ if not file or selected != 'agents':
109
+ yield {status: gr.update(visible=True, value='Upload a patent and select a method first.')}
110
+ return
111
+ if login_needed(visitor):
112
+ yield {modal: gr.update(visible=True), reconnect: gr.update(visible=True),
113
+ start: gr.update(interactive=False),
114
+ status: gr.update(visible=True, value='Connect ChatGPT to continue.')}
115
+ return
116
+ yield {start: gr.update(interactive=False, value='Processing…'), patent: gr.update(interactive=False),
117
+ agents: gr.update(interactive=False), results: gr.update(visible=False),
118
+ reconnect: gr.update(visible=False), status: gr.update(visible=True, value='Processing your patent…'),
119
+ result: '', download: None, preview: '', sysml_text: '', sysml_download: None, research_download: None}
120
+ finished = None
121
+ try:
122
+ with closing(process(file, visitor)) as rows:
123
+ for row in rows:
124
+ finished = row
125
+ yield {status: gr.update(visible=True, value=row[0])}
126
+ if finished:
127
+ updates = dict(zip(output_components, finished))
128
+ updates[status] = gr.update(visible=True, value=finished[0])
129
+ # Failed runs can still expose their retained research archive.
130
+ updates[results] = gr.update(visible=bool(finished[1] or finished[6]))
131
+ yield updates
132
+ except Exception:
133
+ logging.exception('Patent processing failed')
134
+ yield {status: gr.update(visible=True, value='Processing could not finish. Please try again.')}
135
+ yield {start: gr.update(interactive=True, value='Process'), patent: gr.update(interactive=True),
136
+ agents: gr.update(interactive=True)}
137
+
138
+ selection_outputs = [method, agents, modal, start, reconnect, login_status, connect, status]
139
+ agents.click(choose_agents, [patent, session], selection_outputs,
140
+ api_name=False, queue=False, show_progress='hidden')
141
+ reconnect.click(choose_agents, [patent, session], selection_outputs,
142
+ api_name=False, queue=False, show_progress='hidden')
143
+ login_event = connect.click(sign_in, session, [connect, login_status, modal, reconnect, start, status],
144
+ api_name=False, concurrency_limit=4, show_progress='hidden')
145
+ close.click(dismiss_login, outputs=[modal, connect], cancels=[login_event],
146
+ api_name=False, queue=False, show_progress='hidden')
147
+ patent.change(on_upload, patent, [methods, method, agents, disclosure, start, status, modal, reconnect, connect,
148
+ results, result, download, preview, sysml_text, sysml_download, research_download],
149
+ cancels=[login_event], api_name=False, queue=False, show_progress='hidden')
150
+ start.click(run_ui, [patent, method, session],
151
+ [*output_components, start, patent, agents, results, modal, reconnect],
152
+ api_name=False, concurrency_limit=1, concurrency_id='patent_processing', show_progress='hidden')
153
+ # Preserve the existing seven-output API and backend authentication gate.
154
+ gr.Button(visible=False).click(process, [patent, session], output_components,
155
+ api_name='process_patent', concurrency_limit=1, concurrency_id='patent_processing')
156
+ return app