Add transfer tracking and secure admin access
This commit is contained in:
@@ -37,10 +37,11 @@ public partial class OpenAiVisionClient : ILlmClient
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!IsEnabled) return null;
|
||||
const string prompt =
|
||||
"你是记账意图识别器。只返回 JSON:" +
|
||||
"{\"kind\":\"bill\"|\"query\"|\"chat\",\"type\":\"expense\"|\"income\"," +
|
||||
"\"amount\":0,\"categoryName\":\"\",\"note\":\"\"}。" +
|
||||
const string prompt =
|
||||
"你是记账意图识别器。只返回 JSON:" +
|
||||
"{\"kind\":\"bill\"|\"query\"|\"chat\",\"type\":\"expense\"|\"income\"|\"transfer\"," +
|
||||
"\"transferDirection\":\"in\"|\"out\"|null,\"counterparty\":null," +
|
||||
"\"amount\":0,\"categoryName\":\"\",\"note\":\"\"}。" +
|
||||
"收入信号包括赚了、工资到账、奖金、兼职、稿费、红包、报销、退款、理财收益、收款;" +
|
||||
"支出分类:餐饮/饮品/购物/交通/住房/娱乐/医疗/学习/服饰/人情/旅行/其他;" +
|
||||
"收入分类:工资/奖金/理财/兼职/红包/报销/其他。" +
|
||||
@@ -62,8 +63,8 @@ public partial class OpenAiVisionClient : ILlmClient
|
||||
var shanghaiNow = ChinaClock.Now;
|
||||
var systemPrompt = $$"""
|
||||
你是账单截图识别器。逐条提取图片中所有独立、真实发生的交易,只返回 JSON:
|
||||
{"bills":[{"type":"expense","amount":0,"categoryName":"","paymentMethod":null,"note":"","occurredAt":"2026-07-18T14:30:00+08:00"}]}
|
||||
type 只能是 expense 或 income。
|
||||
{"bills":[{"type":"expense","transferDirection":null,"counterparty":null,"amount":0,"categoryName":"","paymentMethod":null,"note":"","occurredAt":"2026-07-18T14:30:00+08:00"}]}
|
||||
type 只能是 expense、income 或 transfer;transfer 必须同时返回 transferDirection=in|out,counterparty 尽量填写转账对方。
|
||||
支出分类只能是:餐饮/饮品/购物/交通/住房/娱乐/医疗/学习/服饰/人情/旅行/其他。
|
||||
收入分类只能是:工资/奖金/理财/兼职/红包/报销/其他。
|
||||
note 只写简短商户、商品或交易对象,不要抄整行原文。
|
||||
@@ -181,11 +182,16 @@ public partial class OpenAiVisionClient : ILlmClient
|
||||
categoryHint = candidate.CategoryHint,
|
||||
confidence = candidate.Confidence,
|
||||
evidenceIds = candidate.EvidenceIds,
|
||||
transferDirection = candidate.TransferDirection,
|
||||
counterparty = candidate.Counterparty,
|
||||
providerTransactionId = candidate.ProviderTransactionId,
|
||||
recognitionOccurrenceId = candidate.RecognitionOccurrenceId,
|
||||
identityConfidence = candidate.IdentityConfidence,
|
||||
}),
|
||||
new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
var systemPrompt = $$"""
|
||||
你是支付结果批次对账器。只返回 JSON:
|
||||
{"actions":[{"action":"keep|update|create|drop","actionId":"a1","candidateId":null,"evidenceId":null,"type":null,"amount":null,"categoryName":null,"paymentMethod":null,"note":null,"occurredAt":null,"confidence":0.0,"reason":""}]}
|
||||
{"actions":[{"action":"keep|update|create|drop","actionId":"a1","candidateId":null,"evidenceId":null,"type":null,"transferDirection":null,"counterparty":null,"amount":null,"categoryName":null,"paymentMethod":null,"note":null,"occurredAt":null,"confidence":0.0,"reason":""}]}
|
||||
当前批次候选:{{candidateJson}}
|
||||
支出分类只能是:{{string.Join('/', input.ExpenseCategories)}}。
|
||||
收入分类只能是:{{string.Join('/', input.IncomeCategories)}}。
|
||||
@@ -193,7 +199,7 @@ public partial class OpenAiVisionClient : ILlmClient
|
||||
不同 flowSessionId 代表不同支付流程。即使收款人、金额和时间相同,也绝不能据此合并或删除。
|
||||
只有截图明确显示失败、取消、待支付,或明确是同一 flowSessionId 的重复结果页时才可 drop。
|
||||
只有存在没有对应候选的 evidenceId 且截图明确显示交易成功时才可 create,并必须引用该 evidenceId。
|
||||
update/create 的 type 只能是 expense 或 income,amount 必须大于 0。
|
||||
update/create 的 type 只能是 expense、income 或 transfer,amount 必须大于 0;transfer 必须返回 transferDirection=in|out。
|
||||
截图未明确显示时间时沿用候选时间,禁止猜测时间。reason 不超过 40 个汉字。
|
||||
""";
|
||||
var messages = new List<object>();
|
||||
@@ -331,7 +337,9 @@ public partial class OpenAiVisionClient : ILlmClient
|
||||
ReadText(item, "note", "merchant")?.Trim(),
|
||||
ReadOccurredAt(item),
|
||||
confidence,
|
||||
reason.Length > 80 ? reason[..80] : reason));
|
||||
reason.Length > 80 ? reason[..80] : reason,
|
||||
ReadText(item, "transferDirection", "transfer_direction")?.Trim().ToLowerInvariant(),
|
||||
ReadText(item, "counterparty")?.Trim()));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
@@ -485,8 +493,9 @@ public partial class OpenAiVisionClient : ILlmClient
|
||||
var rawType = ReadText(item, "type", "transactionType", "direction");
|
||||
var type = rawType?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"income" or "收入" or "入账" => "income",
|
||||
"expense" or "支出" or "出账" => "expense",
|
||||
"income" or "收入" or "入账" => "income",
|
||||
"expense" or "支出" or "出账" => "expense",
|
||||
"transfer" or "转账" => "transfer",
|
||||
_ => null,
|
||||
};
|
||||
if (type is null) return;
|
||||
@@ -508,7 +517,18 @@ public partial class OpenAiVisionClient : ILlmClient
|
||||
"description",
|
||||
"title",
|
||||
"counterparty");
|
||||
var occurredAt = ReadOccurredAt(item);
|
||||
var occurredAt = ReadOccurredAt(item);
|
||||
var transferDirection = type == "transfer"
|
||||
? ReadText(item, "transferDirection", "transfer_direction", "direction")
|
||||
?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"in" or "转入" => "in",
|
||||
"out" or "转出" => "out",
|
||||
_ => null,
|
||||
}
|
||||
: null;
|
||||
if (type == "transfer" && transferDirection is null) return;
|
||||
var counterparty = ReadText(item, "counterparty", "merchant")?.Trim();
|
||||
|
||||
results.Add(new ImageParseResult(
|
||||
type,
|
||||
@@ -516,7 +536,9 @@ public partial class OpenAiVisionClient : ILlmClient
|
||||
string.IsNullOrWhiteSpace(category) ? "其他" : category.Trim(),
|
||||
string.IsNullOrWhiteSpace(payment) ? null : payment.Trim(),
|
||||
string.IsNullOrWhiteSpace(note) ? "" : note.Trim(),
|
||||
occurredAt));
|
||||
occurredAt,
|
||||
transferDirection,
|
||||
string.IsNullOrWhiteSpace(counterparty) ? null : counterparty));
|
||||
}
|
||||
|
||||
private static DateTime? ReadOccurredAt(JsonElement item)
|
||||
@@ -959,12 +981,25 @@ public partial class OpenAiVisionClient : ILlmClient
|
||||
? typeNode.GetString()
|
||||
: null;
|
||||
typeText = typeText?.Trim().ToLowerInvariant();
|
||||
if (typeText is null ||
|
||||
typeText is not ("income" or "expense"))
|
||||
return new IntentResult("chat", null);
|
||||
var type = typeText == "income"
|
||||
? TransactionType.Income
|
||||
: TransactionType.Expense;
|
||||
if (typeText is null ||
|
||||
typeText is not ("income" or "expense" or "transfer"))
|
||||
return new IntentResult("chat", null);
|
||||
var type = typeText switch
|
||||
{
|
||||
"income" => TransactionType.Income,
|
||||
"transfer" => TransactionType.Transfer,
|
||||
_ => TransactionType.Expense,
|
||||
};
|
||||
var transferDirection = type == TransactionType.Transfer
|
||||
? ReadText(document, "transferDirection", "transfer_direction") switch
|
||||
{
|
||||
"in" => TransferDirection.In,
|
||||
"out" => TransferDirection.Out,
|
||||
_ => (TransferDirection?)null,
|
||||
}
|
||||
: null;
|
||||
if (type == TransactionType.Transfer && transferDirection is null)
|
||||
return new IntentResult("chat", null);
|
||||
var category = document.TryGetProperty(
|
||||
"categoryName",
|
||||
out var categoryNode)
|
||||
@@ -983,9 +1018,12 @@ public partial class OpenAiVisionClient : ILlmClient
|
||||
type,
|
||||
0,
|
||||
category,
|
||||
note,
|
||||
amount,
|
||||
null));
|
||||
note,
|
||||
amount,
|
||||
null,
|
||||
null,
|
||||
transferDirection,
|
||||
ReadText(document, "counterparty")?.Trim()));
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user