mobile: add business auxiliary chat entry

This commit is contained in:
2026-05-26 00:30:29 +08:00
parent bf6f41adc9
commit f39e345e91
7 changed files with 360 additions and 4 deletions
+13
View File
@@ -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`)
+15
View File
@@ -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 <member-account> -LoginPassword <member-password>` 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
@@ -154,3 +154,24 @@ List<ReviewChatQuickPrompt> buildLegalChatQuickPrompts() {
),
];
}
List<ReviewChatQuickPrompt> buildBusinessAuxiliaryQuickPrompts() {
return const <ReviewChatQuickPrompt>[
ReviewChatQuickPrompt(
label: '提取履约义务',
prompt: '请提取这份合同的履约义务,按主体、义务内容、期限、交付物和证据列成清单。',
),
ReviewChatQuickPrompt(
label: '工商/备案材料',
prompt: '请列出办理这件事所需的工商/备案材料清单,并按必备、可选和容易遗漏分类。',
),
ReviewChatQuickPrompt(
label: '准备谈判策略',
prompt: '请结合这份材料准备谈判策略,区分必须坚持、可以让步和可交换条件。',
),
ReviewChatQuickPrompt(
label: '法律意见边界',
prompt: '请说明法律意见书的强边界、不能替代的事项以及必须人工复核的前提。',
),
];
}
@@ -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<ReviewChatScreen> createState() => _ReviewChatScreenState();
}
class _ReviewChatScreenState extends State<ReviewChatScreen> {
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<ReviewChatScreen> {
@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<ReviewChatScreen> {
@override
void dispose() {
_controller.dispose();
if (_ownsController) {
_controller.dispose();
}
_inputController.dispose();
super.dispose();
}
@@ -180,6 +187,32 @@ class _ReviewChatScreenState extends State<ReviewChatScreen> {
);
},
),
if (!hasReviewContext) ...[
const SizedBox(height: 16),
ValueListenableBuilder<bool>(
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<List<ChatConversationListItem>>(
valueListenable: _controller.conversations,
@@ -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), <String>[
'提取履约义务',
'工商/备案材料',
'准备谈判策略',
'法律意见边界',
]);
expect(prompts[0].prompt, contains('履约义务'));
expect(prompts[1].prompt, allOf(contains('工商'), contains('备案材料')));
expect(prompts[2].prompt, contains('谈判策略'));
expect(prompts[3].prompt, allOf(contains('法律意见书'), contains('边界')));
});
}
@@ -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<List<ChatConversationListItem>> listConversations(
String accessToken, {
int page = 1,
int pageSize = 20,
}) async {
return const <ChatConversationListItem>[];
}
@override
Future<void> streamChat({
required String accessToken,
required String message,
String? conversationId,
Map<String, dynamic>? reviewContext,
required void Function(ChatStreamEvent event) onEvent,
}) async {
return;
}
}
+138 -2
View File
@@ -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"
)