From f39e345e91cb5942dfd9bf7d9d7ad4ee0eccda73 Mon Sep 17 00:00:00 2001 From: yuqei Date: Tue, 26 May 2026 00:30:29 +0800 Subject: [PATCH] mobile: add business auxiliary chat entry --- mobile/README.md | 13 ++ mobile/STATUS.md | 15 ++ .../chat/domain/review_chat_context.dart | 21 +++ .../chat/presentation/review_chat_screen.dart | 37 ++++- .../chat/domain/review_chat_context_test.dart | 22 +++ .../presentation/review_chat_screen_test.dart | 116 +++++++++++++++ mobile/tools/real-device-smoke.ps1 | 140 +++++++++++++++++- 7 files changed, 360 insertions(+), 4 deletions(-) create mode 100644 mobile/test/features/chat/domain/review_chat_context_test.dart create mode 100644 mobile/test/features/chat/presentation/review_chat_screen_test.dart diff --git a/mobile/README.md b/mobile/README.md index a3266ee9..b252dc63 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -85,6 +85,11 @@ The summary prints: - `DOCUMENTS_TYPE_LIST_OK` - `DOCUMENTS_BUILDER_OK` - `DOCUMENTS_VISIBLE_CODES` +- `BUSINESS_AUXILIARY_PROOF_OK` +- `BUSINESS_AUXILIARY_PROMPTS_OK` +- `BUSINESS_AUXILIARY_TRIGGER_OK` +- `BUSINESS_AUXILIARY_ASSISTANT_OK` +- `BUSINESS_AUXILIARY_VISIBLE_LABELS` - `SMOKE_OK` - `SMOKE_BLOCKER` @@ -120,6 +125,10 @@ Smoke depth options: - starts from the signed-in shell - opens `AI tools -> legal document workspace`, verifies the document type list, and continues toward the builder surface - intended for deeper proof that the legal-document drafting flow remains reachable on the proven X60PRO device profile +- `business-auxiliary` + - starts from the signed-in shell + - opens `AI tools`, verifies the four mobile business-auxiliary quick prompts, and taps the obligation-extraction entry + - intended for deeper proof that the mobile Chat product entry exposes business auxiliary routes directly on device Examples: @@ -135,6 +144,9 @@ powershell -ExecutionPolicy Bypass -File D:\ai-contract-platform\mobile\tools\re # Deeper proof that legal-document drafting remains reachable on device powershell -ExecutionPolicy Bypass -File D:\ai-contract-platform\mobile\tools\real-device-smoke.ps1 -ExpectedIp 192.168.3. -SkipInstall -SmokeDepth documents-workspace + +# Deeper proof that business auxiliary prompts are reachable from the mobile chat entry +powershell -ExecutionPolicy Bypass -File D:\ai-contract-platform\mobile\tools\real-device-smoke.ps1 -ExpectedIp 192.168.3. -SkipInstall -SmokeDepth business-auxiliary ``` When the phone's current wireless-debug port is already known, bypass mDNS discovery with `-DeviceSerial`: @@ -150,6 +162,7 @@ powershell -ExecutionPolicy Bypass -File D:\ai-contract-platform\mobile\tools\re - Contract download is validated - Contract template taxonomy filtering is validated through `contract-taxonomy` smoke depth - Legal-document types, schema loading, and draft persistence are now wired into the mobile app; the `documents-workspace` smoke depth now proves the workspace, type list, and builder entry path on the signed-in X60PRO session +- The `AI tools -> legal chat` surface now exposes four business-auxiliary quick prompts for obligation extraction, filing-materials checklists, negotiation strategy, and legal-opinion boundary fallback - External open/share behavior is OEM-dependent and may route into vendor apps such as the default browser or WeChat - Lightweight mobile notifications now exist for: - review status transitions (`completed / partial / failed`) diff --git a/mobile/STATUS.md b/mobile/STATUS.md index 5fe41c2e..69583f79 100644 --- a/mobile/STATUS.md +++ b/mobile/STATUS.md @@ -117,6 +117,21 @@ Created on 2026-04-18. - the next wireless-debug serial was `192.168.3.93:45167`; the app relaunched into the login form and surfaced that the Chinese IME entered the email domain separator as `。`, so mobile login now normalizes full-width account punctuation before calling the API - after installing the rebuilt APK, `tools/real-device-smoke.ps1 -DeviceSerial 192.168.3.93:45167 -ConfigureDevice -SmokeDepth documents-workspace -LoginAccount -LoginPassword ` returned `SMOKE_OK=True` with `DOCUMENTS_WORKSPACE_READY=True`, `DOCUMENTS_TYPE_LIST_OK=True`, and `DOCUMENTS_BUILDER_OK=True` - artifact bundle: `mobile/qa-artifacts/real-device-smoke/2026-05-25T23-44-09-374/` + - the general mobile legal-chat entry now also exposes 4 `business_auxiliary` quick prompts: + - 履约义务提取 + - 工商/备案材料清单 + - 谈判策略 + - 法律意见书强边界兜底 + - `ReviewChatScreen` now supports controller injection so widget tests can verify the mobile Chat product entry without hitting live APIs + - focused validation now covers both the routing keywords and the mobile UI exposure of the 4 business auxiliary entries: + - `flutter test --no-pub test/features/chat/domain/review_chat_context_test.dart test/features/chat/presentation/review_chat_screen_test.dart` + - full mobile validation passed again: + - `flutter analyze --no-pub` + - `flutter test --no-pub` + - `flutter build apk --debug --no-pub --dart-define=API_BASE_URL=http://192.168.3.90:8001/api/v1 --dart-define=APP_ENV=server-dogfood` + - `tools/real-device-smoke.ps1` now supports a `business-auxiliary` depth for the mobile Chat product entry + - the first `business-auxiliary` real-device retry was blocked by wireless ADB churn again: `adb mdns services` returned no `_adb-tls-connect` endpoint and the previous `192.168.3.93:45167` port refused connection + - latest blocker artifact: `mobile/qa-artifacts/real-device-smoke/2026-05-26T00-26-51-814/` ## Phase 0/1 mobile structure progress diff --git a/mobile/lib/features/chat/domain/review_chat_context.dart b/mobile/lib/features/chat/domain/review_chat_context.dart index 430e93b6..9e3ce777 100644 --- a/mobile/lib/features/chat/domain/review_chat_context.dart +++ b/mobile/lib/features/chat/domain/review_chat_context.dart @@ -154,3 +154,24 @@ List buildLegalChatQuickPrompts() { ), ]; } + +List buildBusinessAuxiliaryQuickPrompts() { + return const [ + ReviewChatQuickPrompt( + label: '提取履约义务', + prompt: '请提取这份合同的履约义务,按主体、义务内容、期限、交付物和证据列成清单。', + ), + ReviewChatQuickPrompt( + label: '工商/备案材料', + prompt: '请列出办理这件事所需的工商/备案材料清单,并按必备、可选和容易遗漏分类。', + ), + ReviewChatQuickPrompt( + label: '准备谈判策略', + prompt: '请结合这份材料准备谈判策略,区分必须坚持、可以让步和可交换条件。', + ), + ReviewChatQuickPrompt( + label: '法律意见边界', + prompt: '请说明法律意见书的强边界、不能替代的事项以及必须人工复核的前提。', + ), + ]; +} diff --git a/mobile/lib/features/chat/presentation/review_chat_screen.dart b/mobile/lib/features/chat/presentation/review_chat_screen.dart index 4f63f0be..90913704 100644 --- a/mobile/lib/features/chat/presentation/review_chat_screen.dart +++ b/mobile/lib/features/chat/presentation/review_chat_screen.dart @@ -14,18 +14,21 @@ class ReviewChatScreen extends StatefulWidget { this.reviewContext, this.initialPrompt, this.embedded = false, + this.controller, }); final ReviewChatContext? reviewContext; final String? initialPrompt; final bool embedded; + final ReviewChatController? controller; @override State createState() => _ReviewChatScreenState(); } class _ReviewChatScreenState extends State { - final ReviewChatController _controller = ReviewChatController(); + late final ReviewChatController _controller; + late final bool _ownsController; final TextEditingController _inputController = TextEditingController(); bool _initialPromptSent = false; @@ -40,6 +43,8 @@ class _ReviewChatScreenState extends State { @override void initState() { super.initState(); + _controller = widget.controller ?? ReviewChatController(); + _ownsController = widget.controller == null; _controller.loadConversations(); WidgetsBinding.instance.addPostFrameCallback((_) { final prompt = widget.initialPrompt?.trim(); @@ -53,7 +58,9 @@ class _ReviewChatScreenState extends State { @override void dispose() { - _controller.dispose(); + if (_ownsController) { + _controller.dispose(); + } _inputController.dispose(); super.dispose(); } @@ -180,6 +187,32 @@ class _ReviewChatScreenState extends State { ); }, ), + if (!hasReviewContext) ...[ + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: _controller.isSending, + builder: (context, isSending, _) { + return AppSectionCard( + title: '业务辅助', + subtitle: '直接进入履约义务提取、工商备案、谈判策略和法律意见书边界兜底。', + child: Wrap( + spacing: 12, + runSpacing: 12, + children: buildBusinessAuxiliaryQuickPrompts() + .map( + (item) => OutlinedButton( + onPressed: isSending + ? null + : () => _sendMessage(item.prompt), + child: Text(item.label), + ), + ) + .toList(growable: false), + ), + ); + }, + ), + ], const SizedBox(height: 16), ValueListenableBuilder>( valueListenable: _controller.conversations, diff --git a/mobile/test/features/chat/domain/review_chat_context_test.dart b/mobile/test/features/chat/domain/review_chat_context_test.dart new file mode 100644 index 00000000..73f90e8e --- /dev/null +++ b/mobile/test/features/chat/domain/review_chat_context_test.dart @@ -0,0 +1,22 @@ +import 'package:ai_contract_mobile/features/chat/domain/review_chat_context.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('business auxiliary quick prompts keep the expected routing keywords', + () { + final prompts = buildBusinessAuxiliaryQuickPrompts(); + + expect(prompts, hasLength(4)); + expect(prompts.map((item) => item.label), [ + '提取履约义务', + '工商/备案材料', + '准备谈判策略', + '法律意见边界', + ]); + + expect(prompts[0].prompt, contains('履约义务')); + expect(prompts[1].prompt, allOf(contains('工商'), contains('备案材料'))); + expect(prompts[2].prompt, contains('谈判策略')); + expect(prompts[3].prompt, allOf(contains('法律意见书'), contains('边界'))); + }); +} diff --git a/mobile/test/features/chat/presentation/review_chat_screen_test.dart b/mobile/test/features/chat/presentation/review_chat_screen_test.dart new file mode 100644 index 00000000..e386d1e8 --- /dev/null +++ b/mobile/test/features/chat/presentation/review_chat_screen_test.dart @@ -0,0 +1,116 @@ +import 'package:ai_contract_mobile/features/auth/domain/auth_session.dart'; +import 'package:ai_contract_mobile/features/chat/application/review_chat_controller.dart'; +import 'package:ai_contract_mobile/features/chat/domain/chat_models.dart'; +import 'package:ai_contract_mobile/features/chat/domain/review_chat_context.dart'; +import 'package:ai_contract_mobile/features/chat/presentation/review_chat_screen.dart'; +import 'package:ai_contract_mobile/shared/network/api_client.dart'; +import 'package:ai_contract_mobile/shared/storage/session_store.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() async { + SessionStore.useStorageDriverForTesting(InMemorySessionStorageDriver()); + await SessionStore.save(_session); + }); + + tearDown(() async { + await SessionStore.clear(); + SessionStore.resetStorageDriverForTesting(); + }); + + testWidgets('shows business auxiliary quick prompts in the general chat', + (tester) async { + final controller = ReviewChatController(apiClient: _EmptyChatApiClient()); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ReviewChatScreen( + embedded: true, + controller: controller, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('业务辅助'), findsOneWidget); + expect(find.text('提取履约义务'), findsOneWidget); + expect(find.text('工商/备案材料'), findsOneWidget); + expect(find.text('准备谈判策略'), findsOneWidget); + expect(find.text('法律意见边界'), findsOneWidget); + }); + + testWidgets('keeps business auxiliary prompts hidden in review context', + (tester) async { + final controller = ReviewChatController(apiClient: _EmptyChatApiClient()); + addTearDown(controller.dispose); + + const reviewContext = ReviewChatContext( + reviewId: 'review-1', + prompt: '请解释总体风险', + metadata: ReviewChatContextMetadata( + reviewTitle: '服务合同', + reviewStatus: 'completed', + contractType: 'service', + overallRisk: 'medium', + reviewOpinion: '整体可接受', + focusLabel: 'risk', + actionLabel: 'follow-up', + focusType: 'review', + ), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ReviewChatScreen( + embedded: true, + controller: controller, + reviewContext: reviewContext, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('业务辅助'), findsNothing); + expect(find.text('提取履约义务'), findsNothing); + }); +} + +const _session = AuthSession( + accessToken: 'token', + userId: 'user-1', + tenantId: 'tenant-1', + username: 'member@example.com', + userDisplayName: 'Member', + role: 'member', + status: 'active', +); + +class _EmptyChatApiClient extends ApiClient { + @override + Future> listConversations( + String accessToken, { + int page = 1, + int pageSize = 20, + }) async { + return const []; + } + + @override + Future streamChat({ + required String accessToken, + required String message, + String? conversationId, + Map? reviewContext, + required void Function(ChatStreamEvent event) onEvent, + }) async { + return; + } +} diff --git a/mobile/tools/real-device-smoke.ps1 b/mobile/tools/real-device-smoke.ps1 index 8a30790c..6971fc16 100644 --- a/mobile/tools/real-device-smoke.ps1 +++ b/mobile/tools/real-device-smoke.ps1 @@ -1,4 +1,4 @@ -param( +param( [string]$ExpectedIp = "192.168.3.", [switch]$ConfigureDevice, [switch]$SkipInstall, @@ -6,7 +6,7 @@ param( [string]$DeviceSerial = "", [string]$LoginAccount = "", [string]$LoginPassword = "", - [ValidateSet("login-only", "shell-only", "share-proof", "contract-taxonomy", "documents-workspace")] + [ValidateSet("login-only", "shell-only", "share-proof", "contract-taxonomy", "documents-workspace", "business-auxiliary")] [string]$SmokeDepth = "shell-only", [string]$ApiHealthUrl = "http://192.168.3.90:8001/health", [string]$WebUrl = "http://192.168.3.90:3003/", @@ -271,6 +271,27 @@ function Get-ContentDescBounds { return $results } +function Get-TextBounds { + param([string]$UiDump) + + $matches = [regex]::Matches( + $UiDump, + 'class="([^"]*)".*?text="([^"]*)".*?bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"' + ) + $results = @() + foreach ($match in $matches) { + $results += [pscustomobject]@{ + Class = $match.Groups[1].Value + Text = $match.Groups[2].Value + Left = [int]$match.Groups[3].Value + Top = [int]$match.Groups[4].Value + Right = [int]$match.Groups[5].Value + Bottom = [int]$match.Groups[6].Value + } + } + return $results +} + function Get-BoundsCenter { param($Bounds) @@ -557,6 +578,82 @@ function Invoke-DocumentsWorkspaceProof { } } +function Invoke-BusinessAuxiliaryProof { + param( + [string]$Serial, + [string]$ArtifactDir, + [string]$PackageName + ) + + $expectedLabels = @( + "提取履约义务", + "工商/备案材料", + "准备谈判策略", + "法律意见边界" + ) + + Invoke-AdbTap -Serial $Serial -X 540 -Y 2057 + Start-Sleep -Seconds 2 + $toolsState = Get-SmokeState -Serial $Serial -Label "business-auxiliary-tools" -ArtifactDir $ArtifactDir -PackageName $PackageName + + $visibleLabels = @() + $targetPrompt = $null + $promptListState = $null + for ($attempt = 1; $attempt -le 4; $attempt++) { + if ($attempt -gt 1) { + Invoke-AdbSwipe -Serial $Serial -StartX 540 -StartY 1800 -EndX 540 -EndY 980 -DurationMs 280 + Start-Sleep -Seconds 2 + } + + $promptListState = Get-SmokeState -Serial $Serial -Label "business-auxiliary-list-attempt-$attempt" -ArtifactDir $ArtifactDir -PackageName $PackageName + foreach ($label in $expectedLabels) { + if ($promptListState.UiDump -match [regex]::Escape($label) -and $visibleLabels -notcontains $label) { + $visibleLabels += $label + } + } + if (-not $targetPrompt) { + $targetPrompt = Get-TextBounds -UiDump $promptListState.UiDump | + Where-Object { $_.Text -eq "提取履约义务" } | + Select-Object -First 1 + } + if ($visibleLabels.Count -eq $expectedLabels.Count) { + break + } + } + + $promptsOk = $true + foreach ($label in $expectedLabels) { + if ($visibleLabels -notcontains $label) { + $promptsOk = $false + break + } + } + + $triggerOk = $false + $assistantResponseOk = $false + if ($targetPrompt) { + $center = Get-BoundsCenter -Bounds $targetPrompt + Invoke-AdbTap -Serial $Serial -X $center.X -Y $center.Y + Start-Sleep -Seconds 2 + $triggerState = Get-SmokeStateWithRetry -Serial $Serial -LabelPrefix "business-auxiliary-trigger" -ArtifactDir $ArtifactDir -PackageName $PackageName -RetryCount 4 -DelaySeconds 2 + $triggerOk = $triggerState.AppVisible -and ($triggerState.Surface -in @("signed_in_shell", "app_visible")) + + Start-Sleep -Seconds 6 + $assistantState = Get-SmokeStateWithRetry -Serial $Serial -LabelPrefix "business-auxiliary-result" -ArtifactDir $ArtifactDir -PackageName $PackageName -RetryCount 4 -DelaySeconds 2 + $assistantResponseOk = $assistantState.AppVisible -and ($assistantState.Surface -in @("signed_in_shell", "app_visible")) + } + + return [pscustomobject]@{ + ToolsSurface = $toolsState.Surface + PromptListSurface = $promptListState.Surface + VisibleLabels = ($visibleLabels -join ",") + PromptsOk = $promptsOk + TriggerOk = $triggerOk + AssistantResponseOk = $assistantResponseOk + SmokeOk = ($promptsOk -and $triggerOk) + } +} + function Try-AutoLogin { param( [string]$Serial, @@ -771,6 +868,11 @@ $documentsWorkspaceReady = "" $documentsTypeListOk = "" $documentsBuilderOk = "" $documentsVisibleCodes = "" +$businessAuxiliaryProofOk = "" +$businessAuxiliaryPromptsOk = "" +$businessAuxiliaryTriggerOk = "" +$businessAuxiliaryAssistantOk = "" +$businessAuxiliaryVisibleLabels = "" switch ($SmokeDepth) { "login-only" { @@ -884,6 +986,35 @@ switch ($SmokeDepth) { $documentsVisibleCodes = $documentsProof.VisibleCodes $smokeOk = $documentsProof.SmokeOk } + "business-auxiliary" { + if ($surface -eq "device_locked") { + $smokeBlocker = "device_locked" + break + } + if ($surface -eq "login_form" -or ($initialState.AppVisible -and $surface -eq "app_visible")) { + $loginState = Try-AutoLogin -Serial $serial -ArtifactDir $artifactDir -PackageName $PackageName -Account $LoginAccount -Password $LoginPassword + if ($loginState -ne $null) { + $initialState = $loginState + $surface = $initialState.Surface + $topActivity = $initialState.TopActivity + } + if ($surface -eq "login_form" -or ($initialState.AppVisible -and $surface -eq "app_visible")) { + $smokeBlocker = "login_required" + break + } + } + if (-not ($initialState.AppVisible -and $surface -eq "signed_in_shell")) { + throw "business-auxiliary requires the app to relaunch into the signed-in shell. Current surface: $surface" + } + + $businessProof = Invoke-BusinessAuxiliaryProof -Serial $serial -ArtifactDir $artifactDir -PackageName $PackageName + $businessAuxiliaryProofOk = $businessProof.SmokeOk + $businessAuxiliaryPromptsOk = $businessProof.PromptsOk + $businessAuxiliaryTriggerOk = $businessProof.TriggerOk + $businessAuxiliaryAssistantOk = $businessProof.AssistantResponseOk + $businessAuxiliaryVisibleLabels = $businessProof.VisibleLabels + $smokeOk = $businessProof.SmokeOk + } } $apiStatusCode = if ($apiStatus) { $apiStatus.StatusCode } else { "" } @@ -912,6 +1043,11 @@ $summaryLines = @( "DOCUMENTS_TYPE_LIST_OK=$documentsTypeListOk", "DOCUMENTS_BUILDER_OK=$documentsBuilderOk", "DOCUMENTS_VISIBLE_CODES=$documentsVisibleCodes", + "BUSINESS_AUXILIARY_PROOF_OK=$businessAuxiliaryProofOk", + "BUSINESS_AUXILIARY_PROMPTS_OK=$businessAuxiliaryPromptsOk", + "BUSINESS_AUXILIARY_TRIGGER_OK=$businessAuxiliaryTriggerOk", + "BUSINESS_AUXILIARY_ASSISTANT_OK=$businessAuxiliaryAssistantOk", + "BUSINESS_AUXILIARY_VISIBLE_LABELS=$businessAuxiliaryVisibleLabels", "ARTIFACT_DIR=$artifactDir", "SMOKE_OK=$smokeOk" )