fix: isolate payment amount evidence
This commit is contained in:
@@ -3,8 +3,9 @@ using System.Net;
|
|||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using MiaoJiZhang.Api.Services;
|
using MiaoJiZhang.Api.Controllers;
|
||||||
|
using MiaoJiZhang.Api.Services;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
using Microsoft.AspNetCore.TestHost;
|
using Microsoft.AspNetCore.TestHost;
|
||||||
@@ -859,6 +860,29 @@ public sealed class RecognitionBatchActionParserTests
|
|||||||
Assert.Equal(1, action.Confidence);
|
Assert.Equal(1, action.Confidence);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class RecognitionAmountUpdateEvidenceTests
|
||||||
|
{
|
||||||
|
private static readonly IReadOnlyDictionary<string, string?> EvidenceOwners =
|
||||||
|
new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["evidence-a"] = "candidate-a",
|
||||||
|
["evidence-b"] = "candidate-b"
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AmountUpdateRequiresLinkedEvidenceAndHighConfidence()
|
||||||
|
{
|
||||||
|
Assert.False(ParseController.CanApplyAmountUpdate(
|
||||||
|
"candidate-a", null, 0.99, EvidenceOwners));
|
||||||
|
Assert.False(ParseController.CanApplyAmountUpdate(
|
||||||
|
"candidate-a", "evidence-a", 0.89, EvidenceOwners));
|
||||||
|
Assert.False(ParseController.CanApplyAmountUpdate(
|
||||||
|
"candidate-a", "evidence-b", 0.99, EvidenceOwners));
|
||||||
|
Assert.True(ParseController.CanApplyAmountUpdate(
|
||||||
|
"candidate-a", "evidence-a", 0.9, EvidenceOwners));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class BudgetRecommendationValidationTests
|
public sealed class BudgetRecommendationValidationTests
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -364,6 +364,9 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
|
|||||||
{
|
{
|
||||||
var candidateIds = manifest.Candidates.Select(item => item.CandidateId).ToHashSet();
|
var candidateIds = manifest.Candidates.Select(item => item.CandidateId).ToHashSet();
|
||||||
var evidenceById = manifest.Evidence.ToDictionary(item => item.EvidenceId);
|
var evidenceById = manifest.Evidence.ToDictionary(item => item.EvidenceId);
|
||||||
|
var evidenceOwners = evidenceById.ToDictionary(
|
||||||
|
entry => entry.Key,
|
||||||
|
entry => entry.Value.CandidateId);
|
||||||
var selected = modelActions
|
var selected = modelActions
|
||||||
.Where(action => action.CandidateId != null && candidateIds.Contains(action.CandidateId))
|
.Where(action => action.CandidateId != null && candidateIds.Contains(action.CandidateId))
|
||||||
.GroupBy(action => action.CandidateId!)
|
.GroupBy(action => action.CandidateId!)
|
||||||
@@ -401,7 +404,19 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
|
|||||||
transferDirection = candidate.TransferDirection;
|
transferDirection = candidate.TransferDirection;
|
||||||
reason = "转账方向不明确,已保留本地结果";
|
reason = "转账方向不明确,已保留本地结果";
|
||||||
}
|
}
|
||||||
var amount = model?.Amount is > 0 ? model.Amount.Value : candidate.Amount;
|
var proposedAmount = model?.Amount is > 0 ? model.Amount.Value : candidate.Amount;
|
||||||
|
var amountChanged = proposedAmount != candidate.Amount;
|
||||||
|
var amountUpdateAllowed = !amountChanged ||
|
||||||
|
model?.Action == "update" && CanApplyAmountUpdate(
|
||||||
|
candidate.CandidateId,
|
||||||
|
model.EvidenceId,
|
||||||
|
model.Confidence,
|
||||||
|
evidenceOwners);
|
||||||
|
var amount = amountUpdateAllowed ? proposedAmount : candidate.Amount;
|
||||||
|
if (amountChanged && !amountUpdateAllowed)
|
||||||
|
{
|
||||||
|
reason = "金额修改证据不足,已保留本地金额";
|
||||||
|
}
|
||||||
var category = FindCategory(
|
var category = FindCategory(
|
||||||
categories,
|
categories,
|
||||||
type == "income" || transferDirection == "in"
|
type == "income" || transferDirection == "in"
|
||||||
@@ -464,6 +479,18 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
|
|||||||
}
|
}
|
||||||
return result.Take(20).ToList();
|
return result.Take(20).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static bool CanApplyAmountUpdate(
|
||||||
|
string candidateId,
|
||||||
|
string? evidenceId,
|
||||||
|
double confidence,
|
||||||
|
IReadOnlyDictionary<string, string?> evidenceOwners)
|
||||||
|
{
|
||||||
|
return confidence >= 0.9 &&
|
||||||
|
!string.IsNullOrWhiteSpace(evidenceId) &&
|
||||||
|
evidenceOwners.TryGetValue(evidenceId, out var owner) &&
|
||||||
|
owner == candidateId;
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<bool> FeatureEnabled(string key)
|
private async Task<bool> FeatureEnabled(string key)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -200,6 +200,7 @@ public partial class OpenAiVisionClient : ILlmClient
|
|||||||
只有截图明确显示失败、取消、待支付,或明确是同一 flowSessionId 的重复结果页时才可 drop。
|
只有截图明确显示失败、取消、待支付,或明确是同一 flowSessionId 的重复结果页时才可 drop。
|
||||||
只有存在没有对应候选的 evidenceId 且截图明确显示交易成功时才可 create,并必须引用该 evidenceId。
|
只有存在没有对应候选的 evidenceId 且截图明确显示交易成功时才可 create,并必须引用该 evidenceId。
|
||||||
update/create 的 type 只能是 expense、income 或 transfer,amount 必须大于 0;transfer 必须返回 transferDirection=in|out。
|
update/create 的 type 只能是 expense、income 或 transfer,amount 必须大于 0;transfer 必须返回 transferDirection=in|out。
|
||||||
|
update 修改 amount 时必须引用属于该 candidateId 的 evidenceId,且 confidence 不低于 0.9;证据不足时保持候选金额。
|
||||||
截图未明确显示时间时沿用候选时间,禁止猜测时间。reason 不超过 40 个汉字。
|
截图未明确显示时间时沿用候选时间,禁止猜测时间。reason 不超过 40 个汉字。
|
||||||
""";
|
""";
|
||||||
var messages = new List<object>();
|
var messages = new List<object>();
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ object LocalPaymentOcr {
|
|||||||
flowSessionId: String,
|
flowSessionId: String,
|
||||||
trustedFlow: Boolean,
|
trustedFlow: Boolean,
|
||||||
capturedAt: Long,
|
capturedAt: Long,
|
||||||
expectedAmountCents: Long?,
|
expectedAmountEvidence: ExpectedAmountEvidence?,
|
||||||
expectedType: String?,
|
expectedType: String?,
|
||||||
resultTransitionObserved: Boolean,
|
resultTransitionObserved: Boolean,
|
||||||
submittedFlow: Boolean,
|
submittedFlow: Boolean,
|
||||||
@@ -120,7 +120,7 @@ object LocalPaymentOcr {
|
|||||||
flowSessionId = flowSessionId,
|
flowSessionId = flowSessionId,
|
||||||
trustedFlow = trustedFlow,
|
trustedFlow = trustedFlow,
|
||||||
capturedAt = capturedAt,
|
capturedAt = capturedAt,
|
||||||
expectedAmountCents = expectedAmountCents,
|
expectedAmountEvidence = expectedAmountEvidence,
|
||||||
expectedType = expectedType,
|
expectedType = expectedType,
|
||||||
resultTransitionObserved = resultTransitionObserved,
|
resultTransitionObserved = resultTransitionObserved,
|
||||||
submittedFlow = submittedFlow,
|
submittedFlow = submittedFlow,
|
||||||
@@ -199,7 +199,7 @@ object LocalPaymentOcr {
|
|||||||
flowSessionId: String,
|
flowSessionId: String,
|
||||||
trustedFlow: Boolean,
|
trustedFlow: Boolean,
|
||||||
capturedAt: Long,
|
capturedAt: Long,
|
||||||
expectedAmountCents: Long?,
|
expectedAmountEvidence: ExpectedAmountEvidence?,
|
||||||
expectedType: String?,
|
expectedType: String?,
|
||||||
resultTransitionObserved: Boolean,
|
resultTransitionObserved: Boolean,
|
||||||
submittedFlow: Boolean,
|
submittedFlow: Boolean,
|
||||||
@@ -322,6 +322,7 @@ object LocalPaymentOcr {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
val distinctCents = ranked.map { (it.amount * 100).roundToLong() }.distinct()
|
val distinctCents = ranked.map { (it.amount * 100).roundToLong() }.distinct()
|
||||||
|
val expectedAmountCents = expectedAmountEvidence?.amountCents
|
||||||
val expectedCandidate = expectedAmountCents?.let { expected ->
|
val expectedCandidate = expectedAmountCents?.let { expected ->
|
||||||
ranked.firstOrNull { (it.amount * 100).roundToLong() == expected }
|
ranked.firstOrNull { (it.amount * 100).roundToLong() == expected }
|
||||||
}
|
}
|
||||||
@@ -348,15 +349,21 @@ object LocalPaymentOcr {
|
|||||||
|
|
||||||
val selectedCents = resultSelectedCents ?: expectedAmountCents!!
|
val selectedCents = resultSelectedCents ?: expectedAmountCents!!
|
||||||
val amountSource = if (resultSelectedCents == null) "expected" else "result"
|
val amountSource = if (resultSelectedCents == null) "expected" else "result"
|
||||||
val expectedMatched = expectedAmountCents?.let { it == selectedCents }
|
val expectedMatched = if (expectedAmountCents != null && resultSelectedCents != null) {
|
||||||
|
expectedAmountCents == resultSelectedCents
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val amountConflict = expectedMatched == false
|
||||||
val fresh = System.currentTimeMillis() - capturedAt <= MAX_SCREENSHOT_AGE_MS
|
val fresh = System.currentTimeMillis() - capturedAt <= MAX_SCREENSHOT_AGE_MS
|
||||||
val resultAmountSafe = distinctCents.size == 1 &&
|
val resultAmountSafe = distinctCents.size == 1 &&
|
||||||
(expectedAmountCents == null || expectedMatched == true)
|
(expectedAmountCents == null || expectedMatched == true)
|
||||||
val highConfidence = when {
|
val highConfidence = when {
|
||||||
amountSource == "expected" -> trustedFlow && fresh && submittedFlow &&
|
amountSource == "expected" -> expectedAmountEvidence?.isStrong == true &&
|
||||||
resultTransitionObserved && expectedType == direction
|
trustedFlow && fresh && submittedFlow && resultTransitionObserved &&
|
||||||
|
expectedType == direction
|
||||||
status.strength == PaymentStatusStrength.STRONG -> trustedFlow && fresh &&
|
status.strength == PaymentStatusStrength.STRONG -> trustedFlow && fresh &&
|
||||||
submittedFlow && resultTransitionObserved && resultAmountSafe
|
submittedFlow && resultTransitionObserved && resultAmountSafe && !amountConflict
|
||||||
status.strength == PaymentStatusStrength.WEAK -> OcrEvidenceEvaluator.qualifiesWeakAuto(
|
status.strength == PaymentStatusStrength.WEAK -> OcrEvidenceEvaluator.qualifiesWeakAuto(
|
||||||
trustedFlow = trustedFlow && submittedFlow,
|
trustedFlow = trustedFlow && submittedFlow,
|
||||||
freshScreenshot = fresh,
|
freshScreenshot = fresh,
|
||||||
@@ -399,8 +406,27 @@ object LocalPaymentOcr {
|
|||||||
if (it == "income") "in" else "out"
|
if (it == "income") "in" else "out"
|
||||||
},
|
},
|
||||||
counterparty = merchant.takeIf { kind == "transfer" },
|
counterparty = merchant.takeIf { kind == "transfer" },
|
||||||
|
amountEvidenceStrength = if (amountSource == "result" ||
|
||||||
|
expectedAmountEvidence?.isStrong == true
|
||||||
|
) {
|
||||||
|
"strong"
|
||||||
|
} else {
|
||||||
|
"weak"
|
||||||
|
},
|
||||||
|
amountCandidateCount = distinctCents.size,
|
||||||
|
expectedAmountMatched = expectedMatched,
|
||||||
|
amountIssueReason = when {
|
||||||
|
amountConflict -> "result_amount_conflict"
|
||||||
|
amountSource == "expected" && expectedAmountEvidence?.isStrong != true ->
|
||||||
|
"weak_expected_fallback"
|
||||||
|
amountSource == "expected" -> "expected_amount_fallback"
|
||||||
|
else -> null
|
||||||
|
},
|
||||||
)
|
)
|
||||||
val reason = when {
|
val reason = when {
|
||||||
|
amountConflict -> "result_amount_conflict"
|
||||||
|
amountSource == "expected" && expectedAmountEvidence?.isStrong != true ->
|
||||||
|
"weak_expected_fallback"
|
||||||
highConfidence && amountSource == "expected" -> "expected_amount_fallback"
|
highConfidence && amountSource == "expected" -> "expected_amount_fallback"
|
||||||
highConfidence && status.strength == PaymentStatusStrength.WEAK ->
|
highConfidence && status.strength == PaymentStatusStrength.WEAK ->
|
||||||
"combined_high_confidence"
|
"combined_high_confidence"
|
||||||
|
|||||||
@@ -25,6 +25,36 @@ data class PaymentSignal(
|
|||||||
val identityConfidence: String = "strong",
|
val identityConfidence: String = "strong",
|
||||||
val transferDirection: String? = null,
|
val transferDirection: String? = null,
|
||||||
val counterparty: String? = null,
|
val counterparty: String? = null,
|
||||||
|
val amountEvidenceStrength: String = "strong",
|
||||||
|
val amountCandidateCount: Int = 1,
|
||||||
|
val expectedAmountMatched: Boolean? = null,
|
||||||
|
val amountIssueReason: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ExpectedAmountEvidence(
|
||||||
|
val amountCents: Long,
|
||||||
|
val flowSessionId: String,
|
||||||
|
val windowId: Int,
|
||||||
|
val pageFingerprint: String,
|
||||||
|
val capturedAtEpochMs: Long,
|
||||||
|
val candidateCount: Int,
|
||||||
|
val source: String,
|
||||||
|
val strength: String,
|
||||||
|
) {
|
||||||
|
val isStrong: Boolean get() = strength == "strong"
|
||||||
|
|
||||||
|
fun belongsTo(flowId: String, flowStartedAt: Long, committedAt: Long?): Boolean =
|
||||||
|
flowSessionId == flowId &&
|
||||||
|
capturedAtEpochMs >= flowStartedAt &&
|
||||||
|
(committedAt == null || capturedAtEpochMs <= committedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class PaymentParseOutcome(
|
||||||
|
val signal: PaymentSignal?,
|
||||||
|
val amountCandidateCount: Int,
|
||||||
|
val resultAmountCents: Long?,
|
||||||
|
val expectedAmountMatched: Boolean?,
|
||||||
|
val reason: String?,
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class PaymentStatusStrength(val wireValue: String) {
|
enum class PaymentStatusStrength(val wireValue: String) {
|
||||||
@@ -44,7 +74,7 @@ object PaymentParser {
|
|||||||
val supportedPackages = setOf(WECHAT, ALIPAY)
|
val supportedPackages = setOf(WECHAT, ALIPAY)
|
||||||
|
|
||||||
private val amountPatterns = listOf(
|
private val amountPatterns = listOf(
|
||||||
Regex("""(?:实付|付款金额|支付金额|收款金额|到账金额|交易金额|金额)[::\s]*[¥¥]?\s*([0-9]+(?:\.[0-9]{1,2})?)"""),
|
Regex("""(?:实付|付款金额|支付金额|转账金额|红包金额|收款金额|到账金额|交易金额|金额)[::\s]*[¥¥]?\s*([0-9]+(?:\.[0-9]{1,2})?)"""),
|
||||||
Regex("""[¥¥]\s*([0-9]+(?:\.[0-9]{1,2})?)"""),
|
Regex("""[¥¥]\s*([0-9]+(?:\.[0-9]{1,2})?)"""),
|
||||||
Regex("""([0-9]+(?:\.[0-9]{1,2})?)\s*元"""),
|
Regex("""([0-9]+(?:\.[0-9]{1,2})?)\s*元"""),
|
||||||
)
|
)
|
||||||
@@ -128,17 +158,47 @@ object PaymentParser {
|
|||||||
windowId: Int,
|
windowId: Int,
|
||||||
flowSessionId: String? = null,
|
flowSessionId: String? = null,
|
||||||
trustedFlow: Boolean = false,
|
trustedFlow: Boolean = false,
|
||||||
expectedAmountCents: Long? = null,
|
expectedAmountEvidence: ExpectedAmountEvidence? = null,
|
||||||
expectedType: String? = null,
|
expectedType: String? = null,
|
||||||
resultTransitionObserved: Boolean = false,
|
resultTransitionObserved: Boolean = false,
|
||||||
submittedFlow: Boolean = false,
|
submittedFlow: Boolean = false,
|
||||||
flowKind: String? = null,
|
flowKind: String? = null,
|
||||||
): PaymentSignal? {
|
): PaymentSignal? = fromAccessibilityOutcome(
|
||||||
|
packageName = packageName,
|
||||||
|
text = text,
|
||||||
|
eventTime = eventTime,
|
||||||
|
windowId = windowId,
|
||||||
|
flowSessionId = flowSessionId,
|
||||||
|
trustedFlow = trustedFlow,
|
||||||
|
expectedAmountEvidence = expectedAmountEvidence,
|
||||||
|
expectedType = expectedType,
|
||||||
|
resultTransitionObserved = resultTransitionObserved,
|
||||||
|
submittedFlow = submittedFlow,
|
||||||
|
flowKind = flowKind,
|
||||||
|
).signal
|
||||||
|
|
||||||
|
fun fromAccessibilityOutcome(
|
||||||
|
packageName: String,
|
||||||
|
text: String,
|
||||||
|
eventTime: Long,
|
||||||
|
windowId: Int,
|
||||||
|
flowSessionId: String? = null,
|
||||||
|
trustedFlow: Boolean = false,
|
||||||
|
expectedAmountEvidence: ExpectedAmountEvidence? = null,
|
||||||
|
expectedType: String? = null,
|
||||||
|
resultTransitionObserved: Boolean = false,
|
||||||
|
submittedFlow: Boolean = false,
|
||||||
|
flowKind: String? = null,
|
||||||
|
): PaymentParseOutcome {
|
||||||
|
val expectedEvidence = expectedAmountEvidence
|
||||||
|
val expectedCents = expectedEvidence?.amountCents
|
||||||
|
val resultCandidates = amountCandidateCents(text)
|
||||||
|
val uniqueResultAmount = resultCandidates.singleOrNull()
|
||||||
if (packageName !in supportedPackages ||
|
if (packageName !in supportedPackages ||
|
||||||
containsBlockedStatus(text) ||
|
containsBlockedStatus(text) ||
|
||||||
isHistoryPageText(text) ||
|
isHistoryPageText(text) ||
|
||||||
isPaymentInputPage(text)
|
isPaymentInputPage(text)
|
||||||
) return null
|
) return PaymentParseOutcome(null, resultCandidates.size, null, null, null)
|
||||||
val eventId = flowSessionId?.let { "a:" + packageName + ":" + it }
|
val eventId = flowSessionId?.let { "a:" + packageName + ":" + it }
|
||||||
?: "a:" + windowId + ":" + sha256(normalize(text)) + ":" + (eventTime / 10_000L)
|
?: "a:" + windowId + ":" + sha256(normalize(text)) + ":" + (eventTime / 10_000L)
|
||||||
val status = detectStatus(text, expectedType)
|
val status = detectStatus(text, expectedType)
|
||||||
@@ -156,46 +216,85 @@ object PaymentParser {
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
if (parsed != null) {
|
if (parsed != null) {
|
||||||
val uniqueResultAmount = uniqueAmountCents(text)
|
|
||||||
val standaloneRedPacketIncome = parsed.recognitionKind in setOf(
|
val standaloneRedPacketIncome = parsed.recognitionKind in setOf(
|
||||||
"red_packet_receive",
|
"red_packet_receive",
|
||||||
"red_packet_refund",
|
"red_packet_refund",
|
||||||
)
|
)
|
||||||
val expectedMatches = expectedAmountCents == null ||
|
val expectedMatches = expectedCents == null ||
|
||||||
uniqueResultAmount == expectedAmountCents
|
uniqueResultAmount == expectedCents
|
||||||
|
val conflict = expectedCents != null && uniqueResultAmount != null &&
|
||||||
|
uniqueResultAmount != expectedCents
|
||||||
val evidenceHigh = when {
|
val evidenceHigh = when {
|
||||||
standaloneRedPacketIncome -> uniqueResultAmount == parsed.amountCents
|
standaloneRedPacketIncome -> uniqueResultAmount == parsed.amountCents
|
||||||
submittedFlow -> trustedFlow && resultTransitionObserved &&
|
submittedFlow -> trustedFlow && resultTransitionObserved &&
|
||||||
uniqueResultAmount == parsed.amountCents && expectedMatches
|
uniqueResultAmount == parsed.amountCents && expectedMatches && !conflict
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
return parsed.copy(
|
val signal = parsed.copy(
|
||||||
evidenceConfidence = if (evidenceHigh) "high" else "confirm",
|
evidenceConfidence = if (evidenceHigh) "high" else "confirm",
|
||||||
|
amountEvidenceStrength = if (uniqueResultAmount != null) "strong" else "weak",
|
||||||
|
amountCandidateCount = resultCandidates.size,
|
||||||
|
expectedAmountMatched = if (expectedCents != null && uniqueResultAmount != null) {
|
||||||
|
expectedMatches
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
amountIssueReason = if (conflict) "result_amount_conflict" else null,
|
||||||
|
)
|
||||||
|
return PaymentParseOutcome(
|
||||||
|
signal,
|
||||||
|
resultCandidates.size,
|
||||||
|
uniqueResultAmount,
|
||||||
|
signal.expectedAmountMatched,
|
||||||
|
signal.amountIssueReason,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val redPacketSentSurface = flowKind == "red_packet_send" &&
|
val redPacketSentSurface = flowKind == "red_packet_send" &&
|
||||||
hasRedPacketSentSurface(text)
|
hasRedPacketSentSurface(text)
|
||||||
if (status.strength == PaymentStatusStrength.NONE && !redPacketSentSurface) return null
|
if (status.strength == PaymentStatusStrength.NONE && !redPacketSentSurface) {
|
||||||
val direction = status.direction ?: if (redPacketSentSurface) "expense" else return null
|
return PaymentParseOutcome(null, resultCandidates.size, null, null, null)
|
||||||
val resultAmount = uniqueAmountCents(text)
|
}
|
||||||
|
val direction = status.direction ?: if (redPacketSentSurface) "expense" else {
|
||||||
|
return PaymentParseOutcome(null, resultCandidates.size, null, null, "direction_unknown")
|
||||||
|
}
|
||||||
|
val resultAmount = uniqueResultAmount
|
||||||
val canUseExpectedAmount = resultAmount == null &&
|
val canUseExpectedAmount = resultAmount == null &&
|
||||||
expectedAmountCents != null &&
|
expectedCents != null &&
|
||||||
submittedFlow &&
|
submittedFlow &&
|
||||||
resultTransitionObserved &&
|
resultTransitionObserved &&
|
||||||
expectedType == direction
|
expectedType == direction
|
||||||
val amountCents = resultAmount ?: expectedAmountCents?.takeIf { canUseExpectedAmount }
|
val amountCents = resultAmount ?: expectedCents?.takeIf { canUseExpectedAmount }
|
||||||
?: return null
|
?: return PaymentParseOutcome(
|
||||||
val expectedMatched = expectedAmountCents != null && amountCents == expectedAmountCents
|
null,
|
||||||
|
resultCandidates.size,
|
||||||
|
resultAmount,
|
||||||
|
null,
|
||||||
|
if (expectedCents == null) "expected_amount_missing" else "missing_amount",
|
||||||
|
)
|
||||||
|
val expectedMatched = if (expectedCents != null && resultAmount != null) {
|
||||||
|
resultAmount == expectedCents
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val conflict = expectedMatched == false
|
||||||
|
val expectedStrong = expectedEvidence?.isStrong == true
|
||||||
val evidenceHigh = trustedFlow && submittedFlow && resultTransitionObserved &&
|
val evidenceHigh = trustedFlow && submittedFlow && resultTransitionObserved &&
|
||||||
expectedMatched && expectedType == direction
|
expectedType == direction && !conflict &&
|
||||||
|
(resultAmount != null || expectedStrong)
|
||||||
val kind = flowKind ?: recognitionKind(text, direction)
|
val kind = flowKind ?: recognitionKind(text, direction)
|
||||||
val merchant = extractMerchant(text)
|
val merchant = extractMerchant(text)
|
||||||
val orderId = extractOrderId(text)
|
val orderId = extractOrderId(text)
|
||||||
val transferDirection = direction.takeIf { kind == "transfer" }?.let {
|
val transferDirection = direction.takeIf { kind == "transfer" }?.let {
|
||||||
if (it == "income") "in" else "out"
|
if (it == "income") "in" else "out"
|
||||||
}
|
}
|
||||||
return PaymentSignal(
|
val issueReason = when {
|
||||||
|
conflict -> "result_amount_conflict"
|
||||||
|
resultAmount == null && !expectedStrong -> "weak_expected_fallback"
|
||||||
|
resultAmount == null -> "expected_amount_fallback"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
val signal = PaymentSignal(
|
||||||
packageName = packageName,
|
packageName = packageName,
|
||||||
channel = "accessibility",
|
channel = "accessibility",
|
||||||
amountCents = amountCents,
|
amountCents = amountCents,
|
||||||
@@ -222,6 +321,17 @@ object PaymentParser {
|
|||||||
),
|
),
|
||||||
transferDirection = transferDirection,
|
transferDirection = transferDirection,
|
||||||
counterparty = merchant.takeIf { kind == "transfer" },
|
counterparty = merchant.takeIf { kind == "transfer" },
|
||||||
|
amountEvidenceStrength = if (resultAmount != null || expectedStrong) "strong" else "weak",
|
||||||
|
amountCandidateCount = resultCandidates.size,
|
||||||
|
expectedAmountMatched = expectedMatched,
|
||||||
|
amountIssueReason = issueReason,
|
||||||
|
)
|
||||||
|
return PaymentParseOutcome(
|
||||||
|
signal,
|
||||||
|
resultCandidates.size,
|
||||||
|
resultAmount,
|
||||||
|
expectedMatched,
|
||||||
|
issueReason,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
fun fromNotification(notification: StatusBarNotification): PaymentSignal? {
|
fun fromNotification(notification: StatusBarNotification): PaymentSignal? {
|
||||||
@@ -313,8 +423,12 @@ object PaymentParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun uniqueAmountCents(value: String): Long? {
|
fun uniqueAmountCents(value: String): Long? {
|
||||||
|
return amountCandidateCents(value).singleOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun amountCandidateCents(value: String): List<Long> {
|
||||||
val normalized = normalize(value)
|
val normalized = normalize(value)
|
||||||
val cents = buildList {
|
return buildList {
|
||||||
amountPatterns.forEach { pattern ->
|
amountPatterns.forEach { pattern ->
|
||||||
pattern.findAll(normalized).forEach { match ->
|
pattern.findAll(normalized).forEach { match ->
|
||||||
match.groupValues.getOrNull(1)?.toDoubleOrNull()?.let { amount ->
|
match.groupValues.getOrNull(1)?.toDoubleOrNull()?.let { amount ->
|
||||||
@@ -332,7 +446,50 @@ object PaymentParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.distinct()
|
}.distinct()
|
||||||
return cents.singleOrNull()
|
}
|
||||||
|
|
||||||
|
fun expectedAmountEvidence(
|
||||||
|
pageText: String,
|
||||||
|
eventText: String,
|
||||||
|
flowSessionId: String,
|
||||||
|
windowId: Int,
|
||||||
|
capturedAtEpochMs: Long,
|
||||||
|
): ExpectedAmountEvidence? {
|
||||||
|
val pageFingerprint = sha256(normalize(pageText))
|
||||||
|
val labeled = LABELED_AMOUNT.findAll(normalize(pageText))
|
||||||
|
.mapNotNull { match ->
|
||||||
|
match.groupValues.getOrNull(1)?.toDoubleOrNull()?.let {
|
||||||
|
(it * 100).roundToLong()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.filter { it > 0 }
|
||||||
|
.distinct()
|
||||||
|
.toList()
|
||||||
|
if (labeled.size == 1) {
|
||||||
|
return ExpectedAmountEvidence(
|
||||||
|
labeled.single(),
|
||||||
|
flowSessionId,
|
||||||
|
windowId,
|
||||||
|
pageFingerprint,
|
||||||
|
capturedAtEpochMs,
|
||||||
|
labeled.size,
|
||||||
|
"labeled_page",
|
||||||
|
"strong",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val eventCandidates = amountCandidateCents(eventText)
|
||||||
|
if (eventCandidates.size != 1) return null
|
||||||
|
val explicitEvent = EXPLICIT_AMOUNT.containsMatchIn(normalize(eventText))
|
||||||
|
return ExpectedAmountEvidence(
|
||||||
|
eventCandidates.single(),
|
||||||
|
flowSessionId,
|
||||||
|
windowId,
|
||||||
|
pageFingerprint,
|
||||||
|
capturedAtEpochMs,
|
||||||
|
eventCandidates.size,
|
||||||
|
"payment_event",
|
||||||
|
if (explicitEvent) "strong" else "weak",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isPaymentInputPage(value: String): Boolean {
|
fun isPaymentInputPage(value: String): Boolean {
|
||||||
@@ -548,6 +705,10 @@ object PaymentParser {
|
|||||||
private val STANDALONE_AMOUNT = Regex(
|
private val STANDALONE_AMOUNT = Regex(
|
||||||
"""^\s*([¥¥]?)\s*([0-9]{1,8}(?:\.[0-9]{1,2})?)\s*(元?)\s*$""",
|
"""^\s*([¥¥]?)\s*([0-9]{1,8}(?:\.[0-9]{1,2})?)\s*(元?)\s*$""",
|
||||||
)
|
)
|
||||||
|
private val LABELED_AMOUNT = Regex(
|
||||||
|
"""(?:实付|付款金额|支付金额|转账金额|红包金额|收款金额|到账金额|交易金额)[:: \t]*(?:\r?\n[:: \t]*)?[¥¥]?[ \t]*([0-9]+(?:\.[0-9]{1,2})?)""",
|
||||||
|
)
|
||||||
|
private val EXPLICIT_AMOUNT = Regex("""[¥¥]\s*[0-9]+(?:\.[0-9]{1,2})?|[0-9]+\.[0-9]{1,2}""")
|
||||||
private const val MAX_SUCCESS_HEADING_CHARS = 48
|
private const val MAX_SUCCESS_HEADING_CHARS = 48
|
||||||
private const val MAX_PAYMENT_SCAN_LINES = 48
|
private const val MAX_PAYMENT_SCAN_LINES = 48
|
||||||
private const val MAX_HISTORY_TITLE_LINES = 3
|
private const val MAX_HISTORY_TITLE_LINES = 3
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ data class PendingRecognitionBatch(
|
|||||||
val images: List<PendingBatchImage>,
|
val images: List<PendingBatchImage>,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
internal data class AmountMergeResult(
|
||||||
|
val amountCents: Long,
|
||||||
|
val source: String,
|
||||||
|
val strength: String,
|
||||||
|
val conflict: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
class RecognitionStore(context: Context) :
|
class RecognitionStore(context: Context) :
|
||||||
SQLiteOpenHelper(context, "recognition_queue.db", null, 3) {
|
SQLiteOpenHelper(context, "recognition_queue.db", null, 3) {
|
||||||
override fun onCreate(db: SQLiteDatabase) {
|
override fun onCreate(db: SQLiteDatabase) {
|
||||||
@@ -178,20 +185,27 @@ class RecognitionStore(context: Context) :
|
|||||||
id = existing.id
|
id = existing.id
|
||||||
val mergedMask = existing.channelMask or channelBit
|
val mergedMask = existing.channelMask or channelBit
|
||||||
val hasNonAiEvidence = mergedMask and channelBit("recognition_ai").inv() != 0
|
val hasNonAiEvidence = mergedMask and channelBit("recognition_ai").inv() != 0
|
||||||
val high = hasNonAiEvidence &&
|
val mergedPayload = mergePayload(existing.payload, signal)
|
||||||
|
val amountConflict = mergedPayload.optBoolean("amountConflict", false)
|
||||||
|
val high = !amountConflict && hasNonAiEvidence &&
|
||||||
(signalHigh ||
|
(signalHigh ||
|
||||||
(mergedMask and ACCESSIBILITY_NOTIFICATION_MASK) ==
|
(mergedMask and ACCESSIBILITY_NOTIFICATION_MASK) ==
|
||||||
ACCESSIBILITY_NOTIFICATION_MASK ||
|
ACCESSIBILITY_NOTIFICATION_MASK ||
|
||||||
existing.highConfidence)
|
existing.highConfidence)
|
||||||
val mergedPayload = mergePayload(existing.payload, signal)
|
|
||||||
writableDatabase.update(
|
writableDatabase.update(
|
||||||
"candidates",
|
"candidates",
|
||||||
ContentValues().apply {
|
ContentValues().apply {
|
||||||
put("channel_mask", mergedMask)
|
put("channel_mask", mergedMask)
|
||||||
put("known_template", if (existing.knownTemplate || signal.knownTemplate) 1 else 0)
|
put("known_template", if (existing.knownTemplate || signal.knownTemplate) 1 else 0)
|
||||||
|
put("amount_cents", kotlin.math.round(mergedPayload.optDouble("amount") * 100).toLong())
|
||||||
|
put("direction", mergedPayload.optString("type", signal.type))
|
||||||
put("high_confidence", if (high) 1 else 0)
|
put("high_confidence", if (high) 1 else 0)
|
||||||
put("payload_encrypted", encryptPayload(mergedPayload))
|
put("payload_encrypted", encryptPayload(mergedPayload))
|
||||||
if (high && existing.state == "pending_confirm") put("state", "auto_ready")
|
if (amountConflict) {
|
||||||
|
put("state", "pending_confirm")
|
||||||
|
} else if (high && existing.state == "pending_confirm") {
|
||||||
|
put("state", "auto_ready")
|
||||||
|
}
|
||||||
put("updated_at", now)
|
put("updated_at", now)
|
||||||
put("available_at", now + MERGE_DELAY_MS)
|
put("available_at", now + MERGE_DELAY_MS)
|
||||||
if (batchId != null) {
|
if (batchId != null) {
|
||||||
@@ -525,9 +539,21 @@ class RecognitionStore(context: Context) :
|
|||||||
val original = JSONObject(row.payload.toString())
|
val original = JSONObject(row.payload.toString())
|
||||||
val kind = action.optString("action")
|
val kind = action.optString("action")
|
||||||
val payload = JSONObject(row.payload.toString())
|
val payload = JSONObject(row.payload.toString())
|
||||||
if (kind == "update") applyActionFields(payload, action)
|
if (kind == "update") {
|
||||||
|
applyActionFields(payload, action)
|
||||||
|
if (payload.optDouble("amount") != original.optDouble("amount")) {
|
||||||
|
payload
|
||||||
|
.put("amountSource", "ai_batch")
|
||||||
|
.put("amountEvidenceStrength", "strong")
|
||||||
|
.put("amountConflict", false)
|
||||||
|
.remove("conflictingAmount")
|
||||||
|
payload.remove("conflictingAmountSource")
|
||||||
|
payload.remove("amountIssueReason")
|
||||||
|
}
|
||||||
|
}
|
||||||
payload.put("sourceOverride", "recognition_ai")
|
payload.put("sourceOverride", "recognition_ai")
|
||||||
val reason = action.optString("reason", "AI 对账")
|
val reason = action.optString("reason", "AI 对账")
|
||||||
|
val amountConflict = payload.optBoolean("amountConflict", false)
|
||||||
writableDatabase.update(
|
writableDatabase.update(
|
||||||
"candidates",
|
"candidates",
|
||||||
ContentValues().apply {
|
ContentValues().apply {
|
||||||
@@ -540,8 +566,15 @@ class RecognitionStore(context: Context) :
|
|||||||
put("channel_mask", row.channelMask or channelBit("recognition_ai"))
|
put("channel_mask", row.channelMask or channelBit("recognition_ai"))
|
||||||
put("ai_action", kind)
|
put("ai_action", kind)
|
||||||
put("ai_reason", reason.take(80))
|
put("ai_reason", reason.take(80))
|
||||||
put("state", if (kind == "drop") "ai_dropped" else "auto_ready")
|
put(
|
||||||
put("high_confidence", 1)
|
"state",
|
||||||
|
when {
|
||||||
|
kind == "drop" -> "ai_dropped"
|
||||||
|
amountConflict -> "pending_confirm"
|
||||||
|
else -> "auto_ready"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
put("high_confidence", if (amountConflict) 0 else 1)
|
||||||
put("updated_at", now)
|
put("updated_at", now)
|
||||||
},
|
},
|
||||||
"id = ? AND batch_id = ?",
|
"id = ? AND batch_id = ?",
|
||||||
@@ -631,14 +664,17 @@ class RecognitionStore(context: Context) :
|
|||||||
// The server guarantees one action per candidate. Preserve anything omitted
|
// The server guarantees one action per candidate. Preserve anything omitted
|
||||||
// by a malformed response instead of silently losing a payment.
|
// by a malformed response instead of silently losing a payment.
|
||||||
writableDatabase.rawQuery(
|
writableDatabase.rawQuery(
|
||||||
"SELECT id FROM candidates WHERE batch_id = ? AND state = 'batch_collecting'",
|
"SELECT id, payload_encrypted FROM candidates WHERE batch_id = ? AND state = 'batch_collecting'",
|
||||||
arrayOf(batchId),
|
arrayOf(batchId),
|
||||||
).use { cursor ->
|
).use { cursor ->
|
||||||
while (cursor.moveToNext()) {
|
while (cursor.moveToNext()) {
|
||||||
|
val amountConflict = decryptPayload(cursor.getString(1))
|
||||||
|
?.optBoolean("amountConflict", false) == true
|
||||||
writableDatabase.update(
|
writableDatabase.update(
|
||||||
"candidates",
|
"candidates",
|
||||||
ContentValues().apply {
|
ContentValues().apply {
|
||||||
put("state", "auto_ready")
|
put("state", if (amountConflict) "pending_confirm" else "auto_ready")
|
||||||
|
put("high_confidence", if (amountConflict) 0 else 1)
|
||||||
put("ai_action", "keep")
|
put("ai_action", "keep")
|
||||||
put("ai_reason", "AI 未返回该候选,已保留本地结果")
|
put("ai_reason", "AI 未返回该候选,已保留本地结果")
|
||||||
put("updated_at", now)
|
put("updated_at", now)
|
||||||
@@ -1011,6 +1047,10 @@ class RecognitionStore(context: Context) :
|
|||||||
.put("recognitionKind", signal.recognitionKind)
|
.put("recognitionKind", signal.recognitionKind)
|
||||||
.put("categoryHint", signal.categoryHint)
|
.put("categoryHint", signal.categoryHint)
|
||||||
.put("amountSource", signal.amountSource)
|
.put("amountSource", signal.amountSource)
|
||||||
|
.put("amountEvidenceStrength", signal.amountEvidenceStrength)
|
||||||
|
.put("amountCandidateCount", signal.amountCandidateCount)
|
||||||
|
.put("expectedAmountMatched", signal.expectedAmountMatched)
|
||||||
|
.put("amountIssueReason", signal.amountIssueReason)
|
||||||
.put("resultFingerprint", signal.resultFingerprint)
|
.put("resultFingerprint", signal.resultFingerprint)
|
||||||
.put("identityConfidence", signal.identityConfidence)
|
.put("identityConfidence", signal.identityConfidence)
|
||||||
.put("transferDirection", signal.transferDirection)
|
.put("transferDirection", signal.transferDirection)
|
||||||
@@ -1043,7 +1083,36 @@ class RecognitionStore(context: Context) :
|
|||||||
if (existing.isNull("sourceText") && signal.sourceText != null) existing.put("sourceText", signal.sourceText)
|
if (existing.isNull("sourceText") && signal.sourceText != null) existing.put("sourceText", signal.sourceText)
|
||||||
if (existing.optString("recognitionKind").isBlank()) existing.put("recognitionKind", signal.recognitionKind)
|
if (existing.optString("recognitionKind").isBlank()) existing.put("recognitionKind", signal.recognitionKind)
|
||||||
if (existing.isNull("categoryHint") && signal.categoryHint != null) existing.put("categoryHint", signal.categoryHint)
|
if (existing.isNull("categoryHint") && signal.categoryHint != null) existing.put("categoryHint", signal.categoryHint)
|
||||||
if (existing.optString("amountSource").isBlank()) existing.put("amountSource", signal.amountSource)
|
val existingAmountCents = kotlin.math.round(existing.optDouble("amount") * 100).toLong()
|
||||||
|
val amountMerge = mergeAmountEvidence(
|
||||||
|
existingAmountCents = existingAmountCents,
|
||||||
|
existingSource = existing.optString("amountSource", "result"),
|
||||||
|
existingStrength = existing.optString("amountEvidenceStrength", "strong"),
|
||||||
|
incoming = signal,
|
||||||
|
)
|
||||||
|
val amountConflict = existing.optBoolean("amountConflict", false) ||
|
||||||
|
amountMerge.conflict
|
||||||
|
existing
|
||||||
|
.put("amount", amountMerge.amountCents / 100.0)
|
||||||
|
.put("amountSource", amountMerge.source)
|
||||||
|
.put("amountEvidenceStrength", amountMerge.strength)
|
||||||
|
.put("amountConflict", amountConflict)
|
||||||
|
.put("amountCandidateCount", maxOf(
|
||||||
|
existing.optInt("amountCandidateCount", 0),
|
||||||
|
signal.amountCandidateCount,
|
||||||
|
))
|
||||||
|
if (amountMerge.conflict) {
|
||||||
|
existing
|
||||||
|
.put("conflictingAmount", signal.amountCents / 100.0)
|
||||||
|
.put("conflictingAmountSource", signal.amountSource)
|
||||||
|
}
|
||||||
|
if (amountConflict) {
|
||||||
|
existing.put("amountIssueReason", "result_amount_conflict")
|
||||||
|
} else if (signal.amountIssueReason != null) {
|
||||||
|
existing.put("amountIssueReason", signal.amountIssueReason)
|
||||||
|
} else if (amountMerge.source == "result") {
|
||||||
|
existing.remove("amountIssueReason")
|
||||||
|
}
|
||||||
if (existing.isNull("resultFingerprint") && signal.resultFingerprint != null) {
|
if (existing.isNull("resultFingerprint") && signal.resultFingerprint != null) {
|
||||||
existing.put("resultFingerprint", signal.resultFingerprint)
|
existing.put("resultFingerprint", signal.resultFingerprint)
|
||||||
}
|
}
|
||||||
@@ -1111,6 +1180,81 @@ class RecognitionStore(context: Context) :
|
|||||||
private const val MAX_BATCH_ITEMS = 10
|
private const val MAX_BATCH_ITEMS = 10
|
||||||
private const val MAX_BATCH_IMAGE_BYTES = 1024 * 1024
|
private const val MAX_BATCH_IMAGE_BYTES = 1024 * 1024
|
||||||
|
|
||||||
|
internal fun mergeAmountEvidence(
|
||||||
|
existingAmountCents: Long,
|
||||||
|
existingSource: String,
|
||||||
|
existingStrength: String,
|
||||||
|
incoming: PaymentSignal,
|
||||||
|
): AmountMergeResult {
|
||||||
|
if (existingAmountCents == incoming.amountCents) {
|
||||||
|
val incomingRank = amountEvidenceRank(
|
||||||
|
incoming.amountSource,
|
||||||
|
incoming.amountEvidenceStrength,
|
||||||
|
)
|
||||||
|
val existingRank = amountEvidenceRank(existingSource, existingStrength)
|
||||||
|
return if (incomingRank > existingRank) {
|
||||||
|
AmountMergeResult(
|
||||||
|
incoming.amountCents,
|
||||||
|
incoming.amountSource,
|
||||||
|
incoming.amountEvidenceStrength,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
AmountMergeResult(
|
||||||
|
existingAmountCents,
|
||||||
|
existingSource,
|
||||||
|
existingStrength,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val incomingRank = amountEvidenceRank(
|
||||||
|
incoming.amountSource,
|
||||||
|
incoming.amountEvidenceStrength,
|
||||||
|
)
|
||||||
|
val existingRank = amountEvidenceRank(existingSource, existingStrength)
|
||||||
|
val bothStrong = existingStrength == "strong" &&
|
||||||
|
incoming.amountEvidenceStrength == "strong"
|
||||||
|
val independentConflict = bothStrong &&
|
||||||
|
(existingSource == "result" || incoming.amountSource == "result")
|
||||||
|
if (independentConflict) {
|
||||||
|
return AmountMergeResult(
|
||||||
|
existingAmountCents,
|
||||||
|
existingSource,
|
||||||
|
existingStrength,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val useIncoming = when {
|
||||||
|
incoming.amountSource == "result" && existingSource != "result" -> true
|
||||||
|
existingSource == "result" && incoming.amountSource != "result" -> false
|
||||||
|
else -> incomingRank > existingRank
|
||||||
|
}
|
||||||
|
return if (useIncoming) {
|
||||||
|
AmountMergeResult(
|
||||||
|
incoming.amountCents,
|
||||||
|
incoming.amountSource,
|
||||||
|
incoming.amountEvidenceStrength,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
AmountMergeResult(
|
||||||
|
existingAmountCents,
|
||||||
|
existingSource,
|
||||||
|
existingStrength,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun amountEvidenceRank(source: String, strength: String): Int = when {
|
||||||
|
source == "result" && strength == "strong" -> 4
|
||||||
|
source == "result" -> 3
|
||||||
|
strength == "strong" -> 2
|
||||||
|
else -> 1
|
||||||
|
}
|
||||||
|
|
||||||
internal fun clientRequestIdFor(signal: PaymentSignal): String {
|
internal fun clientRequestIdFor(signal: PaymentSignal): String {
|
||||||
val basis = when {
|
val basis = when {
|
||||||
!signal.orderId.isNullOrBlank() ->
|
!signal.orderId.isNullOrBlank() ->
|
||||||
|
|||||||
+66
-26
@@ -47,7 +47,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
var kind: String,
|
var kind: String,
|
||||||
var originWindowId: Int = windowId,
|
var originWindowId: Int = windowId,
|
||||||
var originPageHash: String? = null,
|
var originPageHash: String? = null,
|
||||||
var expectedAmountCents: Long? = null,
|
var expectedAmountEvidence: ExpectedAmountEvidence? = null,
|
||||||
var expectedType: String? = null,
|
var expectedType: String? = null,
|
||||||
var committedAt: Long? = null,
|
var committedAt: Long? = null,
|
||||||
var completedAt: Long? = null,
|
var completedAt: Long? = null,
|
||||||
@@ -188,7 +188,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val observed = PaymentParser.fromAccessibility(
|
val observedOutcome = PaymentParser.fromAccessibilityOutcome(
|
||||||
packageName = recognizedPackage,
|
packageName = recognizedPackage,
|
||||||
text = combined,
|
text = combined,
|
||||||
eventTime = currentEvent.eventTime,
|
eventTime = currentEvent.eventTime,
|
||||||
@@ -196,6 +196,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
expectedType = status.direction ?: existingFlow.expectedType,
|
expectedType = status.direction ?: existingFlow.expectedType,
|
||||||
flowKind = currentKind,
|
flowKind = currentKind,
|
||||||
)
|
)
|
||||||
|
val observed = observedOutcome.signal
|
||||||
val resultChanged = observed?.resultFingerprint != null &&
|
val resultChanged = observed?.resultFingerprint != null &&
|
||||||
existingFlow.resultFingerprint != null &&
|
existingFlow.resultFingerprint != null &&
|
||||||
observed.resultFingerprint != existingFlow.resultFingerprint
|
observed.resultFingerprint != existingFlow.resultFingerprint
|
||||||
@@ -229,6 +230,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
nextFlow,
|
nextFlow,
|
||||||
page.nodeCount,
|
page.nodeCount,
|
||||||
"tree",
|
"tree",
|
||||||
|
parseOutcome = observedOutcome,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -267,13 +269,14 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
currentEvent.windowId,
|
currentEvent.windowId,
|
||||||
now,
|
now,
|
||||||
forceNew = existingFlow?.completed == true ||
|
forceNew = existingFlow?.completed == true ||
|
||||||
|
existingFlow?.committedAt != null ||
|
||||||
existingFlow?.packageName != recognizedPackage,
|
existingFlow?.packageName != recognizedPackage,
|
||||||
kind = inferredKind,
|
kind = inferredKind,
|
||||||
)
|
)
|
||||||
else -> existingFlow?.takeIf { !it.completed }
|
else -> existingFlow?.takeIf { !it.completed }
|
||||||
}
|
}
|
||||||
if (armedFlow != null) {
|
if (armedFlow != null) {
|
||||||
updateFlowEvidence(armedFlow, combined)
|
updateFlowEvidence(armedFlow, combined, eventText, now)
|
||||||
if (clickedPaymentAction) {
|
if (clickedPaymentAction) {
|
||||||
armedFlow.committedAt = armedFlow.committedAt ?: now
|
armedFlow.committedAt = armedFlow.committedAt ?: now
|
||||||
armedFlow.expectedType = armedFlow.expectedType ?: "expense"
|
armedFlow.expectedType = armedFlow.expectedType ?: "expense"
|
||||||
@@ -284,19 +287,28 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val directFlow = paymentFlow?.takeIf { !it.completed }
|
val directFlow = paymentFlow?.takeIf { !it.completed }
|
||||||
val directSignal = if (combined.isBlank()) null else PaymentParser.fromAccessibility(
|
val directOutcome = if (combined.isBlank()) null else {
|
||||||
packageName = recognizedPackage,
|
PaymentParser.fromAccessibilityOutcome(
|
||||||
text = combined,
|
packageName = recognizedPackage,
|
||||||
eventTime = currentEvent.eventTime,
|
text = combined,
|
||||||
windowId = currentEvent.windowId,
|
eventTime = currentEvent.eventTime,
|
||||||
flowSessionId = directFlow?.id,
|
windowId = currentEvent.windowId,
|
||||||
trustedFlow = directFlow?.trusted == true,
|
flowSessionId = directFlow?.id,
|
||||||
expectedAmountCents = directFlow?.expectedAmountCents,
|
trustedFlow = directFlow?.trusted == true,
|
||||||
expectedType = directFlow?.expectedType,
|
expectedAmountEvidence = directFlow?.expectedAmountEvidence?.takeIf {
|
||||||
resultTransitionObserved = directFlow?.resultTransitionObserved == true,
|
it.belongsTo(
|
||||||
submittedFlow = directFlow?.committedAt != null,
|
directFlow.id,
|
||||||
flowKind = directFlow?.kind,
|
directFlow.startedAt,
|
||||||
)
|
directFlow.committedAt,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
expectedType = directFlow?.expectedType,
|
||||||
|
resultTransitionObserved = directFlow?.resultTransitionObserved == true,
|
||||||
|
submittedFlow = directFlow?.committedAt != null,
|
||||||
|
flowKind = directFlow?.kind,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val directSignal = directOutcome?.signal
|
||||||
if (directSignal != null) {
|
if (directSignal != null) {
|
||||||
val flow = directFlow ?: armPaymentFlow(
|
val flow = directFlow ?: armPaymentFlow(
|
||||||
recognizedPackage,
|
recognizedPackage,
|
||||||
@@ -313,6 +325,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
flow,
|
flow,
|
||||||
page.nodeCount,
|
page.nodeCount,
|
||||||
"tree",
|
"tree",
|
||||||
|
parseOutcome = directOutcome,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -411,13 +424,35 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
).also { paymentFlow = it }
|
).also { paymentFlow = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateFlowEvidence(flow: PaymentFlow, text: String) {
|
private fun updateFlowEvidence(
|
||||||
if (text.isBlank() || flow.completed || flow.resultTransitionObserved) return
|
flow: PaymentFlow,
|
||||||
|
text: String,
|
||||||
|
eventText: String,
|
||||||
|
now: Long,
|
||||||
|
) {
|
||||||
|
if (text.isBlank() || flow.completed || flow.committedAt != null ||
|
||||||
|
flow.resultTransitionObserved
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if (PaymentParser.detectStatus(text).strength != PaymentStatusStrength.NONE) return
|
if (PaymentParser.detectStatus(text).strength != PaymentStatusStrength.NONE) return
|
||||||
val pageHash = PaymentParser.sha256(text)
|
val pageHash = PaymentParser.sha256(text)
|
||||||
flow.originPageHash = flow.originPageHash ?: pageHash
|
flow.originPageHash = flow.originPageHash ?: pageHash
|
||||||
flow.expectedAmountCents = flow.expectedAmountCents
|
val observed = PaymentParser.expectedAmountEvidence(
|
||||||
?: PaymentParser.uniqueAmountCents(text)
|
pageText = text,
|
||||||
|
eventText = eventText,
|
||||||
|
flowSessionId = flow.id,
|
||||||
|
windowId = flow.windowId,
|
||||||
|
capturedAtEpochMs = now,
|
||||||
|
)
|
||||||
|
if (observed != null) {
|
||||||
|
val existing = flow.expectedAmountEvidence
|
||||||
|
if (existing == null || observed.isStrong && !existing.isStrong ||
|
||||||
|
observed.source == existing.source
|
||||||
|
) {
|
||||||
|
flow.expectedAmountEvidence = observed
|
||||||
|
}
|
||||||
|
}
|
||||||
flow.expectedType = flow.expectedType
|
flow.expectedType = flow.expectedType
|
||||||
?: PaymentParser.inferContextDirection(text)
|
?: PaymentParser.inferContextDirection(text)
|
||||||
PaymentParser.detectFlowKind(text)?.let { detected ->
|
PaymentParser.detectFlowKind(text)?.let { detected ->
|
||||||
@@ -468,6 +503,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
stage: String,
|
stage: String,
|
||||||
recordDiagnostic: Boolean = true,
|
recordDiagnostic: Boolean = true,
|
||||||
evidenceImage: ByteArray? = null,
|
evidenceImage: ByteArray? = null,
|
||||||
|
parseOutcome: PaymentParseOutcome? = null,
|
||||||
) {
|
) {
|
||||||
if (flow.completed) {
|
if (flow.completed) {
|
||||||
evidenceImage?.fill(0)
|
evidenceImage?.fill(0)
|
||||||
@@ -503,11 +539,13 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
"confirm"
|
"confirm"
|
||||||
},
|
},
|
||||||
nodeCount = nodeCount,
|
nodeCount = nodeCount,
|
||||||
amountCandidates = 1,
|
amountCandidates = parseOutcome?.amountCandidateCount
|
||||||
reason = if (batchEnabled) "queued_for_ai" else "success",
|
?: signal.amountCandidateCount,
|
||||||
expectedAmountMatched = flow.expectedAmountCents?.let {
|
reason = signal.amountIssueReason
|
||||||
it == signal.amountCents
|
?: parseOutcome?.reason
|
||||||
},
|
?: if (batchEnabled) "queued_for_ai" else "success",
|
||||||
|
expectedAmountMatched = parseOutcome?.expectedAmountMatched
|
||||||
|
?: signal.expectedAmountMatched,
|
||||||
resultTransitionObserved = flow.resultTransitionObserved,
|
resultTransitionObserved = flow.resultTransitionObserved,
|
||||||
recognitionKind = signal.recognitionKind,
|
recognitionKind = signal.recognitionKind,
|
||||||
amountSource = signal.amountSource,
|
amountSource = signal.amountSource,
|
||||||
@@ -671,7 +709,9 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
flowSessionId = flow.id,
|
flowSessionId = flow.id,
|
||||||
trustedFlow = flow.trusted,
|
trustedFlow = flow.trusted,
|
||||||
capturedAt = capturedAt,
|
capturedAt = capturedAt,
|
||||||
expectedAmountCents = flow.expectedAmountCents,
|
expectedAmountEvidence = flow.expectedAmountEvidence?.takeIf {
|
||||||
|
it.belongsTo(flow.id, flow.startedAt, flow.committedAt)
|
||||||
|
},
|
||||||
expectedType = flow.expectedType,
|
expectedType = flow.expectedType,
|
||||||
resultTransitionObserved = visualTransitionObserved,
|
resultTransitionObserved = visualTransitionObserved,
|
||||||
submittedFlow = flow.committedAt != null,
|
submittedFlow = flow.committedAt != null,
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ class PaymentParserTest {
|
|||||||
windowId = 7,
|
windowId = 7,
|
||||||
flowSessionId = "flow-a",
|
flowSessionId = "flow-a",
|
||||||
trustedFlow = true,
|
trustedFlow = true,
|
||||||
expectedAmountCents = 2_000L,
|
expectedAmountEvidence = expectedEvidence(2_000L, "strong", "flow-a"),
|
||||||
expectedType = "expense",
|
expectedType = "expense",
|
||||||
resultTransitionObserved = true,
|
resultTransitionObserved = true,
|
||||||
submittedFlow = true,
|
submittedFlow = true,
|
||||||
@@ -107,7 +107,7 @@ class PaymentParserTest {
|
|||||||
windowId = 7,
|
windowId = 7,
|
||||||
flowSessionId = "flow-b",
|
flowSessionId = "flow-b",
|
||||||
trustedFlow = true,
|
trustedFlow = true,
|
||||||
expectedAmountCents = 3_000L,
|
expectedAmountEvidence = expectedEvidence(3_000L, "strong", "flow-b"),
|
||||||
expectedType = "expense",
|
expectedType = "expense",
|
||||||
resultTransitionObserved = true,
|
resultTransitionObserved = true,
|
||||||
)
|
)
|
||||||
@@ -149,7 +149,7 @@ class PaymentParserTest {
|
|||||||
windowId = 8,
|
windowId = 8,
|
||||||
flowSessionId = "payment-flow",
|
flowSessionId = "payment-flow",
|
||||||
trustedFlow = true,
|
trustedFlow = true,
|
||||||
expectedAmountCents = 1_880L,
|
expectedAmountEvidence = expectedEvidence(1_880L, "strong", "payment-flow"),
|
||||||
expectedType = "expense",
|
expectedType = "expense",
|
||||||
resultTransitionObserved = true,
|
resultTransitionObserved = true,
|
||||||
submittedFlow = true,
|
submittedFlow = true,
|
||||||
@@ -171,7 +171,7 @@ class PaymentParserTest {
|
|||||||
windowId = 8,
|
windowId = 8,
|
||||||
flowSessionId = "payment-flow",
|
flowSessionId = "payment-flow",
|
||||||
trustedFlow = true,
|
trustedFlow = true,
|
||||||
expectedAmountCents = 1_880L,
|
expectedAmountEvidence = expectedEvidence(1_880L, "strong", "payment-flow"),
|
||||||
expectedType = "expense",
|
expectedType = "expense",
|
||||||
resultTransitionObserved = true,
|
resultTransitionObserved = true,
|
||||||
submittedFlow = false,
|
submittedFlow = false,
|
||||||
@@ -190,7 +190,7 @@ class PaymentParserTest {
|
|||||||
windowId = 9,
|
windowId = 9,
|
||||||
flowSessionId = "red-packet-flow",
|
flowSessionId = "red-packet-flow",
|
||||||
trustedFlow = true,
|
trustedFlow = true,
|
||||||
expectedAmountCents = 2_000L,
|
expectedAmountEvidence = expectedEvidence(2_000L, "strong", "red-packet-flow"),
|
||||||
expectedType = "expense",
|
expectedType = "expense",
|
||||||
resultTransitionObserved = true,
|
resultTransitionObserved = true,
|
||||||
submittedFlow = true,
|
submittedFlow = true,
|
||||||
@@ -381,6 +381,166 @@ class PaymentParserTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun expectedAmountEvidenceIgnoresUnlabelledChatHistory() {
|
||||||
|
val evidence = PaymentParser.expectedAmountEvidence(
|
||||||
|
pageText = "聊天记录\n¥0.01\n你发起了一笔转账\n¥0.02\n确认转账",
|
||||||
|
eventText = "确认转账",
|
||||||
|
flowSessionId = "transfer-003",
|
||||||
|
windowId = 7,
|
||||||
|
capturedAtEpochMs = 1_000L,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNull(evidence)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun labeledCurrentTransferAmountWinsOverChatHistory() {
|
||||||
|
val evidence = PaymentParser.expectedAmountEvidence(
|
||||||
|
pageText = "聊天记录\n¥0.01\n¥0.02\n转账金额\n¥0.03\n确认转账",
|
||||||
|
eventText = "确认转账",
|
||||||
|
flowSessionId = "transfer-003",
|
||||||
|
windowId = 7,
|
||||||
|
capturedAtEpochMs = 1_000L,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(3L, evidence?.amountCents)
|
||||||
|
assertEquals("strong", evidence?.strength)
|
||||||
|
assertEquals("labeled_page", evidence?.source)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun labeledAmountMustBeOnTheSameOrAdjacentNode() {
|
||||||
|
val evidence = PaymentParser.expectedAmountEvidence(
|
||||||
|
pageText = "转账金额\n付款说明\n¥0.01",
|
||||||
|
eventText = "确认转账",
|
||||||
|
flowSessionId = "transfer-003",
|
||||||
|
windowId = 7,
|
||||||
|
capturedAtEpochMs = 1_000L,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNull(evidence)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resultWithoutAmountUsesOnlyStrongCurrentFlowEvidence() {
|
||||||
|
val outcome = PaymentParser.fromAccessibilityOutcome(
|
||||||
|
packageName = PaymentParser.WECHAT,
|
||||||
|
text = "转账成功",
|
||||||
|
eventTime = 2_000L,
|
||||||
|
windowId = 7,
|
||||||
|
flowSessionId = "transfer-003",
|
||||||
|
trustedFlow = true,
|
||||||
|
expectedAmountEvidence = expectedEvidence(3L, "strong"),
|
||||||
|
expectedType = "expense",
|
||||||
|
resultTransitionObserved = true,
|
||||||
|
submittedFlow = true,
|
||||||
|
flowKind = "transfer",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(3L, outcome.signal?.amountCents)
|
||||||
|
assertEquals("high", outcome.signal?.evidenceConfidence)
|
||||||
|
assertEquals("expected_amount_fallback", outcome.reason)
|
||||||
|
assertNull(outcome.expectedAmountMatched)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun weakFallbackAndConflictingResultRequireConfirmation() {
|
||||||
|
val weak = PaymentParser.fromAccessibilityOutcome(
|
||||||
|
packageName = PaymentParser.WECHAT,
|
||||||
|
text = "转账成功",
|
||||||
|
eventTime = 2_000L,
|
||||||
|
windowId = 7,
|
||||||
|
flowSessionId = "transfer-003",
|
||||||
|
trustedFlow = true,
|
||||||
|
expectedAmountEvidence = expectedEvidence(3L, "weak"),
|
||||||
|
expectedType = "expense",
|
||||||
|
resultTransitionObserved = true,
|
||||||
|
submittedFlow = true,
|
||||||
|
flowKind = "transfer",
|
||||||
|
)
|
||||||
|
val conflict = PaymentParser.fromAccessibilityOutcome(
|
||||||
|
packageName = PaymentParser.WECHAT,
|
||||||
|
text = "转账成功\n¥0.01",
|
||||||
|
eventTime = 2_000L,
|
||||||
|
windowId = 7,
|
||||||
|
flowSessionId = "transfer-003",
|
||||||
|
trustedFlow = true,
|
||||||
|
expectedAmountEvidence = expectedEvidence(3L, "strong"),
|
||||||
|
expectedType = "expense",
|
||||||
|
resultTransitionObserved = true,
|
||||||
|
submittedFlow = true,
|
||||||
|
flowKind = "transfer",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("confirm", weak.signal?.evidenceConfidence)
|
||||||
|
assertEquals("weak_expected_fallback", weak.reason)
|
||||||
|
assertEquals(1L, conflict.signal?.amountCents)
|
||||||
|
assertEquals("confirm", conflict.signal?.evidenceConfidence)
|
||||||
|
assertEquals(false, conflict.expectedAmountMatched)
|
||||||
|
assertEquals("result_amount_conflict", conflict.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun strongerResultReplacesWeakFallbackButStrongConflictStaysFlagged() {
|
||||||
|
val resultSignal = paymentSignal(
|
||||||
|
channel = "local_ocr",
|
||||||
|
sourceEventId = "ocr:wechat:transfer-003",
|
||||||
|
flowSessionId = "transfer-003",
|
||||||
|
).copy(
|
||||||
|
amountCents = 3L,
|
||||||
|
amountSource = "result",
|
||||||
|
amountEvidenceStrength = "strong",
|
||||||
|
)
|
||||||
|
val replacement = RecognitionStore.mergeAmountEvidence(
|
||||||
|
existingAmountCents = 1L,
|
||||||
|
existingSource = "expected",
|
||||||
|
existingStrength = "weak",
|
||||||
|
incoming = resultSignal,
|
||||||
|
)
|
||||||
|
val conflict = RecognitionStore.mergeAmountEvidence(
|
||||||
|
existingAmountCents = 1L,
|
||||||
|
existingSource = "expected",
|
||||||
|
existingStrength = "strong",
|
||||||
|
incoming = resultSignal,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(3L, replacement.amountCents)
|
||||||
|
assertFalse(replacement.conflict)
|
||||||
|
assertEquals(1L, conflict.amountCents)
|
||||||
|
assertTrue(conflict.conflict)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun consecutiveSmallTransfersKeepFourFlowsAndSumToSevenCents() {
|
||||||
|
val amounts = listOf(1L, 1L, 2L, 3L)
|
||||||
|
val signals = amounts.mapIndexed { index, cents ->
|
||||||
|
paymentSignal(
|
||||||
|
channel = "accessibility",
|
||||||
|
sourceEventId = "a:wechat:transfer-$index",
|
||||||
|
flowSessionId = "transfer-$index",
|
||||||
|
).copy(amountCents = cents)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(7L, signals.sumOf { it.amountCents })
|
||||||
|
assertEquals(4, signals.map(RecognitionStore::flowStrongKeyFor).toSet().size)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun expectedEvidence(
|
||||||
|
amountCents: Long,
|
||||||
|
strength: String,
|
||||||
|
flowSessionId: String = "transfer-003",
|
||||||
|
) = ExpectedAmountEvidence(
|
||||||
|
amountCents = amountCents,
|
||||||
|
flowSessionId = flowSessionId,
|
||||||
|
windowId = 7,
|
||||||
|
pageFingerprint = "payment-page",
|
||||||
|
capturedAtEpochMs = 1_500L,
|
||||||
|
candidateCount = 1,
|
||||||
|
source = "labeled_page",
|
||||||
|
strength = strength,
|
||||||
|
)
|
||||||
|
|
||||||
private fun paymentSignal(
|
private fun paymentSignal(
|
||||||
channel: String,
|
channel: String,
|
||||||
sourceEventId: String,
|
sourceEventId: String,
|
||||||
|
|||||||
@@ -80,7 +80,10 @@ class RecognitionDiagnosticDisplay {
|
|||||||
'direction_unknown' => '识别到完成状态,但无法确认收支方向',
|
'direction_unknown' => '识别到完成状态,但无法确认收支方向',
|
||||||
'missing_amount' => '成功状态已识别,但没有找到金额',
|
'missing_amount' => '成功状态已识别,但没有找到金额',
|
||||||
'expected_amount_missing' => '结果页没有金额,且付款前金额不唯一或未捕获',
|
'expected_amount_missing' => '结果页没有金额,且付款前金额不唯一或未捕获',
|
||||||
'expected_amount_fallback' => '结果页金额缺失,已使用付款前确认的唯一金额',
|
'expected_amount_fallback' => '结果页未读到金额,已使用本次付款流程确认的金额',
|
||||||
|
'weak_expected_fallback' => '结果页未读到金额,付款前金额来源不可靠,请确认后入账',
|
||||||
|
'result_amount_conflict' => '结果页金额与本次付款金额不一致,请确认后入账',
|
||||||
|
'amount_source_untrusted' => '金额无法与当前付款流程可靠关联,请确认后入账',
|
||||||
'red_packet_not_settled' => '红包尚未明确到账或退回,不会自动入账',
|
'red_packet_not_settled' => '红包尚未明确到账或退回,不会自动入账',
|
||||||
'duplicate_result_surface' => '同一结果页已处理,本次刷新已忽略',
|
'duplicate_result_surface' => '同一结果页已处理,本次刷新已忽略',
|
||||||
'weak_status_confirm' => '只识别到弱完成状态,组合证据不足,需确认后入账',
|
'weak_status_confirm' => '只识别到弱完成状态,组合证据不足,需确认后入账',
|
||||||
@@ -123,7 +126,7 @@ class RecognitionDiagnosticDisplay {
|
|||||||
|
|
||||||
static String? _amountSourceLabel(String? value) {
|
static String? _amountSourceLabel(String? value) {
|
||||||
return switch (value) {
|
return switch (value) {
|
||||||
'expected' => '使用付款前金额',
|
'expected' => '使用本次付款金额',
|
||||||
'result' => '使用结果页金额',
|
'result' => '使用结果页金额',
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
@@ -172,5 +175,8 @@ class RecognitionDiagnosticDisplay {
|
|||||||
'red_packet_not_settled',
|
'red_packet_not_settled',
|
||||||
'weak_status_confirm',
|
'weak_status_confirm',
|
||||||
'ambiguous_or_unarmed',
|
'ambiguous_or_unarmed',
|
||||||
|
'weak_expected_fallback',
|
||||||
|
'result_amount_conflict',
|
||||||
|
'amount_source_untrusted',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,5 +163,17 @@ void main() {
|
|||||||
).summaryLabel,
|
).summaryLabel,
|
||||||
'已合并',
|
'已合并',
|
||||||
);
|
);
|
||||||
|
expect(
|
||||||
|
RecognitionDiagnosticDisplay.from(
|
||||||
|
diagnostic('confirm', 'expected_amount_fallback'),
|
||||||
|
).reasonLabel,
|
||||||
|
'结果页未读到金额,已使用本次付款流程确认的金额',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
RecognitionDiagnosticDisplay.from(
|
||||||
|
diagnostic('confirm', 'result_amount_conflict'),
|
||||||
|
).reasonLabel,
|
||||||
|
'结果页金额与本次付款金额不一致,请确认后入账',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user