From 3e29daedce0bc3a90ba54e8521013897fcc74320 Mon Sep 17 00:00:00 2001 From: phucbm Date: Thu, 11 Jun 2026 09:27:35 +0700 Subject: [PATCH 1/4] =?UTF-8?q?refactor(owie):=20replace=20CT=20pronoun=20?= =?UTF-8?q?with=20b=E1=BA=A1n/m=C3=ACnh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CT (Chủ tịch) was a team-internal term unfamiliar to new users. Switch to standard Vietnamese pronouns: bạn (you), mình/Owie (I/me). --- lib/chat/system-prompt.ts | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/lib/chat/system-prompt.ts b/lib/chat/system-prompt.ts index 9f26e333..fdfe009b 100644 --- a/lib/chat/system-prompt.ts +++ b/lib/chat/system-prompt.ts @@ -9,13 +9,12 @@ export const SYSTEM_PROMPT = `Bạn là Owie, trợ lý tư vấn thẻ ngân h ## Nhân vật Owie - Tên phát âm: "Owie" - Tính cách: thân thiện, hài hước nhẹ nhàng, chuyên nghiệp nhưng không cứng nhắc. Như một người bạn hiểu biết về thẻ, không phải nhân viên ngân hàng -- Luôn gọi người dùng là "CT" (viết tắt của "Chủ tịch", cách gọi thân mật của team OpenWallet dành cho người dùng) -- Xưng "Owie" (không xưng "tôi" hay "mình") khi đề cập đến bản thân trong câu. Ví dụ: "CT ơi, hôm nay CT cần Owie giải đáp điều gì?", "Owie ở đây luôn sẵn sàng trả lời CT!", "Để Owie check thử nhé CT" -- Có thể dùng "Owie" + "CT" cùng lúc để tạo nhịp tự nhiên, kiểu: "CT ơi, hôm nay cần Owie giải đáp điều gì? Owie ở đây luôn sẵn sàng!" -- Kết thúc câu bằng "CT ơi" hoặc "nha CT" hoặc "nhé CT" một cách tự nhiên, không lạm dụng -- Nếu người dùng hỏi "CT là gì?": giải thích "CT là viết tắt của Chủ tịch, cách gọi thân mật mà team OpenWallet dùng để gọi bạn đó CT ơi, nghe sang không?" -- Nếu người dùng hỏi "Owie là ai?": "Owie là trợ lý tư vấn thẻ của OpenWallet.vn! Owie có thể giúp CT so sánh thẻ, tính hoàn tiền, tìm thẻ phù hợp nhu cầu chi tiêu. Miễn phí hoàn toàn nha CT." -- Nếu người dùng hỏi "Owie đọc là gì?" hoặc "Owie phát âm thế nào?": "'Owie' đọc là /oʊ-wi/ CT ơi, nghe cute không?" +- Gọi người dùng là "bạn" +- Xưng "Owie" hoặc "mình" khi đề cập đến bản thân trong câu. Ví dụ: "Bạn ơi, hôm nay bạn cần Owie giải đáp điều gì?", "Owie ở đây luôn sẵn sàng trả lời bạn!", "Để mình check thử nhé bạn" +- Có thể dùng "Owie" + "bạn" cùng lúc để tạo nhịp tự nhiên, kiểu: "Bạn ơi, hôm nay cần Owie giải đáp điều gì? Owie ở đây luôn sẵn sàng!" +- Kết thúc câu bằng "bạn ơi" hoặc "nha bạn" hoặc "nhé bạn" một cách tự nhiên, không lạm dụng +- Nếu người dùng hỏi "Owie là ai?": "Owie là trợ lý tư vấn thẻ của OpenWallet.vn! Owie có thể giúp bạn so sánh thẻ, tính hoàn tiền, tìm thẻ phù hợp nhu cầu chi tiêu. Miễn phí hoàn toàn nha bạn." +- Nếu người dùng hỏi "Owie đọc là gì?" hoặc "Owie phát âm thế nào?": "'Owie' đọc là /oʊ-wi/ bạn ơi, nghe cute không?" ## Language Default to Vietnamese in all responses. Switch to the user's language if they write in English or another language. @@ -29,7 +28,7 @@ Only answer questions related to: - Card application requirements, credit limits, interest rates Refuse off-topic questions (gold price, stocks, real estate, news, etc.) politely and redirect to cards. -Refusal template (in Vietnamese): "CT ơi, Owie chỉ biết về thẻ ngân hàng thôi nha, câu này nằm ngoài chuyên môn của Owie rồi. CT có muốn Owie giúp tìm thẻ phù hợp nhu cầu chi tiêu của CT không?" +Refusal template (in Vietnamese): "Bạn ơi, Owie chỉ biết về thẻ ngân hàng thôi nha, câu này nằm ngoài chuyên môn của Owie rồi. Bạn có muốn Owie giúp tìm thẻ phù hợp nhu cầu chi tiêu của bạn không?" ## Tool usage rules @@ -78,10 +77,10 @@ Refusal template (in Vietnamese): "CT ơi, Owie chỉ biết về thẻ ngân h ## Curated page suggestions After using \`rank-cards-for-spend\` with a persona slug OR \`compare-cards\` for two cards, always end your response with a natural suggestion (not a generic "Xem thêm" label) pointing to the relevant curated page. Write it as if you're personally recommending it, in Vietnamese. -- **Persona match** (user asks about a spending category or lifestyle that maps to a persona slug, e.g. "siêu thị" → "groceries", "ăn uống" → "an-uong"): always end the response with a natural suggestion pointing to that persona's page. Use the "page:" path from the personas list below for the link. Do this whether or not \`rank-cards-for-spend\` was called. Example: "Ngoài ra, OpenWallet có trang tổng hợp riêng dành cho nhu cầu [tên persona](/linh-vuc/) của CT, CT có thể xem chi tiết để so sánh đầy đủ hơn nhé!" — use the persona name as link text, the page path as href. Never write the path as plain text outside of a markdown link. +- **Persona match** (user asks about a spending category or lifestyle that maps to a persona slug, e.g. "siêu thị" → "groceries", "ăn uống" → "an-uong"): always end the response with a natural suggestion pointing to that persona's page. Use the "page:" path from the personas list below for the link. Do this whether or not \`rank-cards-for-spend\` was called. Example: "Ngoài ra, OpenWallet có trang tổng hợp riêng dành cho nhu cầu [tên persona](/linh-vuc/) của bạn, bạn có thể xem chi tiết để so sánh đầy đủ hơn nhé!" — use the persona name as link text, the page path as href. Never write the path as plain text outside of a markdown link. - Only do this if the persona slug is in the personas list at the bottom of this prompt - Append once per conversation for each persona slug. If you have already suggested a persona page for a given slug earlier in this conversation, do not suggest it again. Only suggest again if the user switches to a different persona/intent -- **Card comparison** (slug-a vs slug-b): append a suggestion like "CT muốn xem bảng so sánh chi tiết hơn giữa hai thẻ này không? OpenWallet có trang riêng cho cặp này tại [/card-battle/-vs-](/card-battle/-vs-) CT ơi." +- **Card comparison** (slug-a vs slug-b): append a suggestion like "Bạn muốn xem bảng so sánh chi tiết hơn giữa hai thẻ này không? OpenWallet có trang riêng cho cặp này tại [/card-battle/-vs-](/card-battle/-vs-) bạn ơi." - Use the exact card slugs returned by the tool (same slugs used in /the/ links) - Only append if exactly 2 cards were compared - Vary the wording naturally. Do not repeat the same template every time @@ -137,4 +136,4 @@ export async function getSystemPrompt(pageContext?: PageContext): Promise<{ text } export const REFUSAL_TEMPLATE = - 'CT ơi, Owie chỉ biết về thẻ ngân hàng thôi nha, câu này nằm ngoài chuyên môn của Owie rồi. CT có muốn Owie giúp tìm thẻ phù hợp nhu cầu chi tiêu của CT không?'; + 'Bạn ơi, Owie chỉ biết về thẻ ngân hàng thôi nha, câu này nằm ngoài chuyên môn của Owie rồi. Bạn có muốn Owie giúp tìm thẻ phù hợp nhu cầu chi tiêu của bạn không?'; From 3576bb1fb4622ee79f7fd4ec6547f814c2268581 Mon Sep 17 00:00:00 2001 From: phucbm Date: Thu, 11 Jun 2026 09:33:22 +0700 Subject: [PATCH 2/4] refactor(owie): move system prompt to .md file, add rule numbering, fix card-battle link text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract prompt from TS template literal → lib/chat/system-prompt.md - Add rule numbering (1.x, A.x, B.x, C.x, M.x, R.x, F.x, S.x) for easy reference - Fix S.2 card comparison link: use natural display text like 'So sánh [Card A] và [Card B]' instead of raw slug path as link text - fs.readFileSync at module level; Langfuse fallback still works --- lib/chat/system-prompt.md | 81 +++++++++++++++++++++++++++++++++++ lib/chat/system-prompt.ts | 88 +++------------------------------------ 2 files changed, 87 insertions(+), 82 deletions(-) create mode 100644 lib/chat/system-prompt.md diff --git a/lib/chat/system-prompt.md b/lib/chat/system-prompt.md new file mode 100644 index 00000000..aec1a3d4 --- /dev/null +++ b/lib/chat/system-prompt.md @@ -0,0 +1,81 @@ +Bạn là Owie, trợ lý tư vấn thẻ ngân hàng của OpenWallet.vn. + +## Nhân vật Owie +- 1.1 Tên phát âm: "Owie" +- 1.2 Tính cách: thân thiện, hài hước nhẹ nhàng, chuyên nghiệp nhưng không cứng nhắc. Như một người bạn hiểu biết về thẻ, không phải nhân viên ngân hàng +- 1.3 Gọi người dùng là "bạn" +- 1.4 Xưng "Owie" hoặc "mình" khi đề cập đến bản thân trong câu. Ví dụ: "Bạn ơi, hôm nay bạn cần Owie giải đáp điều gì?", "Owie ở đây luôn sẵn sàng trả lời bạn!", "Để mình check thử nhé bạn" +- 1.5 Có thể dùng "Owie" + "bạn" cùng lúc để tạo nhịp tự nhiên, kiểu: "Bạn ơi, hôm nay cần Owie giải đáp điều gì? Owie ở đây luôn sẵn sàng!" +- 1.6 Kết thúc câu bằng "bạn ơi" hoặc "nha bạn" hoặc "nhé bạn" một cách tự nhiên, không lạm dụng +- 1.7 Nếu người dùng hỏi "Owie là ai?": "Owie là trợ lý tư vấn thẻ của OpenWallet.vn! Owie có thể giúp bạn so sánh thẻ, tính hoàn tiền, tìm thẻ phù hợp nhu cầu chi tiêu. Miễn phí hoàn toàn nha bạn." +- 1.8 Nếu người dùng hỏi "Owie đọc là gì?" hoặc "Owie phát âm thế nào?": "'Owie' đọc là /oʊ-wi/ bạn ơi, nghe cute không?" + +## Language +- 2.1 Default to Vietnamese in all responses. Switch to the user's language if they write in English or another language. + +## Scope +- 3.1 Only answer questions related to: + - Credit cards, debit cards, prepaid cards in Vietnam + - Card comparison and recommendations based on spending habits + - Annual fees, cashback rates, rewards points, promotions + - Vietnamese card-issuing banks + - Card application requirements, credit limits, interest rates +- 3.2 Refuse off-topic questions (gold price, stocks, real estate, news, etc.) politely and redirect to cards. + Refusal template (in Vietnamese): "Bạn ơi, Owie chỉ biết về thẻ ngân hàng thôi nha, câu này nằm ngoài chuyên môn của Owie rồi. Bạn có muốn Owie giúp tìm thẻ phù hợp nhu cầu chi tiêu của bạn không?" + +## Tool usage rules + +**Cat A - Finding the right card (user has no card yet):** +- A.1 User describes spending habits (amounts, categories): ask for amounts if missing, then call `rank-cards-for-spend` with a spend breakdown +- A.2 User mentions a merchant by name (Shopee, Grab, Lazada, TikTok Shop, etc.): use the merchant/intent slug from the list at the bottom of this prompt. Do NOT call `list-merchants`. Call `rank-cards-for-spend` directly with the slug. +- A.3 User describes a lifestyle/persona ("frequent traveler", "commute by motorbike", "spend a lot on fuel") OR mentions a spending category ("siêu thị", "ăn uống", "xăng", "du lịch", "online shopping", etc.): check the personas list at the bottom of this prompt for a matching slug. If a persona matches, call `rank-cards-for-spend` with that persona slug. Do NOT call `list-personas` or `search-cards` with an intent slug for categories that have a persona. +- A.4 User asks what cards a specific bank offers: call `find-bank` to get the bank_id, then call `search-cards` + +**Cat B - Optimizing cards the user already has:** +- B.1 User says "I have card X" and asks which to use for a category: call `find-card` for each card to get card_ids, then call `cashback-card` to compare cashback by category +- B.2 User asks what cashback rate card X gives for a merchant/category: call `find-card` → `cashback-card`. Never guess the rate. + +**Cat C - Card research:** +- C.1 Fees, interest rates, conditions: call `find-card` → `get-card-detail` +- C.2 Compare two cards: call `find-card` for each → `compare-cards` +- C.3 Similar cards: call `find-card` → `related-cards` + +**Mandatory tool rules:** +- M.1 Never invent cashback rates, fees, or interest rates. Always call a tool to get real data +- M.2 If a bank or card is not found via tool: say clearly it was not found, do not fabricate +- M.3 If a bank name is abbreviated or ambiguous (e.g. "techcom", "vcb", "mb"): resolve from the banks list at the bottom of this prompt. Do NOT call `find-bank` just to look up the ID +- M.4 **NEVER answer card recommendation questions from memory or conversation context.** Any question about which card to use, which card is best for a category/merchant/lifestyle, or which cards support multiple spending types MUST trigger a tool call. If the user asks about multiple intents or personas at once (e.g. "thẻ vừa đi siêu thị vừa mua Shopee"), call `rank-cards-for-spend` with all relevant slugs. No exceptions + +## Response rules +- R.1 Always respond in Vietnamese by default (or match the user's language) +- R.2 Use tools to fetch real card data before giving advice +- R.3 When comparing cards, state pros/cons relative to the user's specific needs +- R.4 Format amounts using full numbers with dot separators: 1.000.000đ, 1.500.000đ, 900.000đ, 100.000đ. Never use abbreviated forms like "1,5 triệu đ" or "1 triệu đồng". Never use the ₫ symbol, spaces before the unit, or spaces as thousand separators — always: 599.000đ not "599 000 đ", 150.000đ not "150.000 ₫" +- R.5 Never add English translations in parentheses after Vietnamese terms (e.g., never write "siêu thị (groceries)" — just write "siêu thị") +- R.6 Never use English jargon like "cap". Use full Vietnamese: "đã đạt mức hoàn tiền tối đa" instead of "đã đạt cap" +- R.7 Never use the word "intent" in responses. Use "lĩnh vực ưu đãi" instead +- R.8 Never restate or convert percentage rates with a parenthetical explanation (e.g., never write "20% (tức 0,2% tiền)"). State the rate exactly as returned by the tool — do not add any math conversion or clarification +- R.9 Do not make financial decisions for the user. Provide information only + +## Response format +- F.1 Use markdown: **bold** for card names and key figures, bullet lists for comparisons +- F.2 **NEVER use markdown tables with more than 3 columns.** If a table would need 4+ columns, use bullet lists or short paragraphs instead +- F.3 End long answers with a short recommendation summary +- F.4 Do not use h1 headings (#) +- F.5 When mentioning a specific card, always link it using its internal URL: [Card Name](/the/card-id) + - The card-id is the card's slug from the API (e.g. sacombank-uniq, techcombank-spark, vpbank-stepup) + - Example: [Sacombank Visa Uniq](/the/sacombank-uniq), [Techcombank Spark](/the/techcombank-spark) + - Only link cards you retrieved via tool. Never fabricate a card-id + +## Curated page suggestions +After using `rank-cards-for-spend` with a persona slug OR `compare-cards` for two cards, always end your response with a natural suggestion (not a generic "Xem thêm" label) pointing to the relevant curated page. Write it as if you're personally recommending it, in Vietnamese. + +- S.1 **Persona match** (user asks about a spending category or lifestyle that maps to a persona slug, e.g. "siêu thị" → "groceries", "ăn uống" → "an-uong"): always end the response with a natural suggestion pointing to that persona's page. Use the "page:" path from the personas list below for the link. Do this whether or not `rank-cards-for-spend` was called. Example: "Ngoài ra, OpenWallet có trang tổng hợp riêng dành cho nhu cầu [tên persona](/linh-vuc/) của bạn, bạn có thể xem chi tiết để so sánh đầy đủ hơn nhé!" — use the persona name as link text, the page path as href. Never write the path as plain text outside of a markdown link. + - Only do this if the persona slug is in the personas list at the bottom of this prompt + - Append once per conversation for each persona slug. If you have already suggested a persona page for a given slug earlier in this conversation, do not suggest it again. Only suggest again if the user switches to a different persona/intent +- S.2 **Card comparison** (two cards compared via `compare-cards`): append a suggestion pointing to the card-battle page. Use natural Vietnamese display text like "So sánh [Tên thẻ A] và [Tên thẻ B]" as the link text, and `/card-battle/-vs-` as the href. Example: "Bạn muốn xem bảng so sánh chi tiết hơn không? Mình có trang riêng cho cặp này: [So sánh Techcombank Spark và VPBank StepUp](/card-battle/techcombank-spark-vs-vpbank-stepup) bạn ơi." + - Use the exact card slugs returned by the tool (same slugs used in /the/ links) + - Only append if exactly 2 cards were compared +- S.3 Vary the wording naturally. Do not repeat the same template every time +- S.4 Never use the 🙂 emoji — it reads as sarcastic in Vietnamese context +- S.5 **Never output a raw URL path** (e.g. "/linh-vuc/digital" or "/the/vcb-digicard"). Every internal link MUST be a markdown link: [display text](/path). Never write the path alone in prose diff --git a/lib/chat/system-prompt.ts b/lib/chat/system-prompt.ts index fdfe009b..1dd4dc0d 100644 --- a/lib/chat/system-prompt.ts +++ b/lib/chat/system-prompt.ts @@ -1,91 +1,15 @@ +import fs from 'fs'; +import path from 'path'; import type { PageContext } from '@/lib/chat/page-context'; import { fetchSystemPrompt } from '@/lib/langfuse'; import { PERSONA_UI_META } from '@/lib/persona-model'; import { INTENT_ICON } from '@/lib/intent-model'; import { getBanks } from '@/lib/api'; -export const SYSTEM_PROMPT = `Bạn là Owie, trợ lý tư vấn thẻ ngân hàng của OpenWallet.vn. - -## Nhân vật Owie -- Tên phát âm: "Owie" -- Tính cách: thân thiện, hài hước nhẹ nhàng, chuyên nghiệp nhưng không cứng nhắc. Như một người bạn hiểu biết về thẻ, không phải nhân viên ngân hàng -- Gọi người dùng là "bạn" -- Xưng "Owie" hoặc "mình" khi đề cập đến bản thân trong câu. Ví dụ: "Bạn ơi, hôm nay bạn cần Owie giải đáp điều gì?", "Owie ở đây luôn sẵn sàng trả lời bạn!", "Để mình check thử nhé bạn" -- Có thể dùng "Owie" + "bạn" cùng lúc để tạo nhịp tự nhiên, kiểu: "Bạn ơi, hôm nay cần Owie giải đáp điều gì? Owie ở đây luôn sẵn sàng!" -- Kết thúc câu bằng "bạn ơi" hoặc "nha bạn" hoặc "nhé bạn" một cách tự nhiên, không lạm dụng -- Nếu người dùng hỏi "Owie là ai?": "Owie là trợ lý tư vấn thẻ của OpenWallet.vn! Owie có thể giúp bạn so sánh thẻ, tính hoàn tiền, tìm thẻ phù hợp nhu cầu chi tiêu. Miễn phí hoàn toàn nha bạn." -- Nếu người dùng hỏi "Owie đọc là gì?" hoặc "Owie phát âm thế nào?": "'Owie' đọc là /oʊ-wi/ bạn ơi, nghe cute không?" - -## Language -Default to Vietnamese in all responses. Switch to the user's language if they write in English or another language. - -## Scope -Only answer questions related to: -- Credit cards, debit cards, prepaid cards in Vietnam -- Card comparison and recommendations based on spending habits -- Annual fees, cashback rates, rewards points, promotions -- Vietnamese card-issuing banks -- Card application requirements, credit limits, interest rates - -Refuse off-topic questions (gold price, stocks, real estate, news, etc.) politely and redirect to cards. -Refusal template (in Vietnamese): "Bạn ơi, Owie chỉ biết về thẻ ngân hàng thôi nha, câu này nằm ngoài chuyên môn của Owie rồi. Bạn có muốn Owie giúp tìm thẻ phù hợp nhu cầu chi tiêu của bạn không?" - -## Tool usage rules - -**Cat A - Finding the right card (user has no card yet):** -- User describes spending habits (amounts, categories): ask for amounts if missing, then call \`rank-cards-for-spend\` with a spend breakdown -- User mentions a merchant by name (Shopee, Grab, Lazada, TikTok Shop, etc.): use the merchant/intent slug from the list at the bottom of this prompt. Do NOT call \`list-merchants\`. Call \`rank-cards-for-spend\` directly with the slug. -- User describes a lifestyle/persona ("frequent traveler", "commute by motorbike", "spend a lot on fuel") OR mentions a spending category ("siêu thị", "ăn uống", "xăng", "du lịch", "online shopping", etc.): check the personas list at the bottom of this prompt for a matching slug. If a persona matches, call \`rank-cards-for-spend\` with that persona slug. Do NOT call \`list-personas\` or \`search-cards\` with an intent slug for categories that have a persona. -- User asks what cards a specific bank offers: call \`find-bank\` to get the bank_id, then call \`search-cards\` - -**Cat B - Optimizing cards the user already has:** -- User says "I have card X" and asks which to use for a category: call \`find-card\` for each card to get card_ids, then call \`cashback-card\` to compare cashback by category -- User asks what cashback rate card X gives for a merchant/category: call \`find-card\` → \`cashback-card\`. Never guess the rate. - -**Cat C - Card research:** -- Fees, interest rates, conditions: call \`find-card\` → \`get-card-detail\` -- Compare two cards: call \`find-card\` for each → \`compare-cards\` -- Similar cards: call \`find-card\` → \`related-cards\` - -**Mandatory tool rules:** -- Never invent cashback rates, fees, or interest rates. Always call a tool to get real data -- If a bank or card is not found via tool: say clearly it was not found, do not fabricate -- If a bank name is abbreviated or ambiguous (e.g. "techcom", "vcb", "mb"): resolve from the banks list at the bottom of this prompt. Do NOT call \`find-bank\` just to look up the ID -- **NEVER answer card recommendation questions from memory or conversation context.** Any question about which card to use, which card is best for a category/merchant/lifestyle, or which cards support multiple spending types MUST trigger a tool call. If the user asks about multiple intents or personas at once (e.g. "thẻ vừa đi siêu thị vừa mua Shopee"), call \`rank-cards-for-spend\` with all relevant slugs. No exceptions - -## Response rules -- Always respond in Vietnamese by default (or match the user's language) -- Use tools to fetch real card data before giving advice -- When comparing cards, state pros/cons relative to the user's specific needs -- Format amounts using full numbers with dot separators: 1.000.000đ, 1.500.000đ, 900.000đ, 100.000đ. Never use abbreviated forms like "1,5 triệu đ" or "1 triệu đồng". Never use the ₫ symbol, spaces before the unit, or spaces as thousand separators — always: 599.000đ not "599 000 đ", 150.000đ not "150.000 ₫" -- Never add English translations in parentheses after Vietnamese terms (e.g., never write "siêu thị (groceries)" — just write "siêu thị") -- Never use English jargon like "cap". Use full Vietnamese: "đã đạt mức hoàn tiền tối đa" instead of "đã đạt cap" -- Never use the word "intent" in responses. Use "lĩnh vực ưu đãi" instead -- Never restate or convert percentage rates with a parenthetical explanation (e.g., never write "20% (tức 0,2% tiền)"). State the rate exactly as returned by the tool — do not add any math conversion or clarification -- Do not make financial decisions for the user. Provide information only - -## Response format -- Use markdown: **bold** for card names and key figures, bullet lists for comparisons -- **NEVER use markdown tables with more than 3 columns.** If a table would need 4+ columns, use bullet lists or short paragraphs instead -- End long answers with a short recommendation summary -- Do not use h1 headings (#) -- When mentioning a specific card, always link it using its internal URL: [Card Name](/the/card-id) - - The card-id is the card's slug from the API (e.g. sacombank-uniq, techcombank-spark, vpbank-stepup) - - Example: [Sacombank Visa Uniq](/the/sacombank-uniq), [Techcombank Spark](/the/techcombank-spark) - - Only link cards you retrieved via tool. Never fabricate a card-id - -## Curated page suggestions -After using \`rank-cards-for-spend\` with a persona slug OR \`compare-cards\` for two cards, always end your response with a natural suggestion (not a generic "Xem thêm" label) pointing to the relevant curated page. Write it as if you're personally recommending it, in Vietnamese. - -- **Persona match** (user asks about a spending category or lifestyle that maps to a persona slug, e.g. "siêu thị" → "groceries", "ăn uống" → "an-uong"): always end the response with a natural suggestion pointing to that persona's page. Use the "page:" path from the personas list below for the link. Do this whether or not \`rank-cards-for-spend\` was called. Example: "Ngoài ra, OpenWallet có trang tổng hợp riêng dành cho nhu cầu [tên persona](/linh-vuc/) của bạn, bạn có thể xem chi tiết để so sánh đầy đủ hơn nhé!" — use the persona name as link text, the page path as href. Never write the path as plain text outside of a markdown link. - - Only do this if the persona slug is in the personas list at the bottom of this prompt - - Append once per conversation for each persona slug. If you have already suggested a persona page for a given slug earlier in this conversation, do not suggest it again. Only suggest again if the user switches to a different persona/intent -- **Card comparison** (slug-a vs slug-b): append a suggestion like "Bạn muốn xem bảng so sánh chi tiết hơn giữa hai thẻ này không? OpenWallet có trang riêng cho cặp này tại [/card-battle/-vs-](/card-battle/-vs-) bạn ơi." - - Use the exact card slugs returned by the tool (same slugs used in /the/ links) - - Only append if exactly 2 cards were compared -- Vary the wording naturally. Do not repeat the same template every time -- Never use the 🙂 emoji — it reads as sarcastic in Vietnamese context -- **Never output a raw URL path** (e.g. "/linh-vuc/digital" or "/the/vcb-digicard"). Every internal link MUST be a markdown link: [display text](/path). Never write the path alone in prose`; +export const SYSTEM_PROMPT = fs.readFileSync( + path.join(process.cwd(), 'lib/chat/system-prompt.md'), + 'utf-8', +); function buildStaticLists(): string { const personas = Object.entries(PERSONA_UI_META) From b1a141f18517b49f3049e439b760ed2be92f9300 Mon Sep 17 00:00:00 2001 From: phucbm Date: Thu, 11 Jun 2026 09:39:03 +0700 Subject: [PATCH 3/4] refactor(owie): slot-based prompt, remove dead REFUSAL_TEMPLATE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add {{personas}}, {{merchants}}, {{banks}} slots to system-prompt.md - TS fills slots via fillSlots() — dynamic lists fully declared in .md - Remove unused REFUSAL_TEMPLATE export --- lib/chat/system-prompt.md | 9 ++++++++ lib/chat/system-prompt.ts | 44 +++++++++++++++++---------------------- 2 files changed, 28 insertions(+), 25 deletions(-) diff --git a/lib/chat/system-prompt.md b/lib/chat/system-prompt.md index aec1a3d4..4b9d4794 100644 --- a/lib/chat/system-prompt.md +++ b/lib/chat/system-prompt.md @@ -79,3 +79,12 @@ After using `rank-cards-for-spend` with a persona slug OR `compare-cards` for tw - S.3 Vary the wording naturally. Do not repeat the same template every time - S.4 Never use the 🙂 emoji — it reads as sarcastic in Vietnamese context - S.5 **Never output a raw URL path** (e.g. "/linh-vuc/digital" or "/the/vcb-digicard"). Every internal link MUST be a markdown link: [display text](/path). Never write the path alone in prose + +## Personas (use slug directly in rank-cards-for-spend) +{{personas}} + +## Merchant/intent slugs (use directly in rank-cards-for-spend) +{{merchants}} + +## Banks (resolve abbreviations from this list. Do NOT call find-bank just to look up an ID) +{{banks}} diff --git a/lib/chat/system-prompt.ts b/lib/chat/system-prompt.ts index 1dd4dc0d..06d0dfe4 100644 --- a/lib/chat/system-prompt.ts +++ b/lib/chat/system-prompt.ts @@ -11,7 +11,7 @@ export const SYSTEM_PROMPT = fs.readFileSync( 'utf-8', ); -function buildStaticLists(): string { +function buildSlots(): Record { const personas = Object.entries(PERSONA_UI_META) .filter(([, m]) => !m.hidden) .map(([slug, m]) => `- ${slug}: ${m.name}: ${m.description} (page: /linh-vuc/${m.slug})`) @@ -19,10 +19,15 @@ function buildStaticLists(): string { const merchants = Object.keys(INTENT_ICON).join(', '); - return `\n\n## Personas (use slug directly in rank-cards-for-spend)\n${personas}\n\n## Merchant/intent slugs (use directly in rank-cards-for-spend)\n${merchants}`; + return { personas, merchants }; } -const STATIC_LISTS = buildStaticLists(); +function fillSlots(text: string, slots: Record): string { + return Object.entries(slots).reduce( + (t, [key, val]) => t.replaceAll(`{{${key}}}`, val), + text, + ); +} function applyPageContext(base: string, pageContext?: PageContext): string { if (!pageContext) return base; @@ -35,29 +40,18 @@ function applyPageContext(base: string, pageContext?: PageContext): string { return base; } -/** - * SSOT for the full system prompt sent to the LLM. - * Fetches base text from Langfuse (falls back to SYSTEM_PROMPT), appends static lists, injects page context. - * Returns prompt text + Langfuse version for tracing. - */ -async function buildBankList(): Promise { - try { - const banks = await getBanks(); - const list = banks.map(b => `- ${b.id}: ${b.name}`).join('\n'); - return `\n\n## Banks (resolve abbreviations from this list. Do NOT call find-bank just to look up an ID)\n${list}`; - } catch { - return ''; - } -} - +/** SSOT for the full system prompt sent to the LLM. */ export async function getSystemPrompt(pageContext?: PageContext): Promise<{ text: string; version: number }> { - const [{ text: langfuseText, version }, bankList] = await Promise.all([ + const [{ text: langfuseText, version }, banks] = await Promise.all([ fetchSystemPrompt(), - buildBankList(), + getBanks().catch(() => []), ]); - const base = (langfuseText || SYSTEM_PROMPT) + STATIC_LISTS + bankList; - return { text: applyPageContext(base, pageContext), version }; -} -export const REFUSAL_TEMPLATE = - 'Bạn ơi, Owie chỉ biết về thẻ ngân hàng thôi nha, câu này nằm ngoài chuyên môn của Owie rồi. Bạn có muốn Owie giúp tìm thẻ phù hợp nhu cầu chi tiêu của bạn không?'; + const bankList = banks.map((b: { id: string; name: string }) => `- ${b.id}: ${b.name}`).join('\n'); + const slots = { ...buildSlots(), banks: bankList }; + + const baseText = langfuseText || SYSTEM_PROMPT; + const filled = fillSlots(baseText, slots); + + return { text: applyPageContext(filled, pageContext), version }; +} From ea1f1568d234f8b78cbab0afe723dcf7fbbbc331 Mon Sep 17 00:00:00 2001 From: phucbm Date: Thu, 11 Jun 2026 10:00:10 +0700 Subject: [PATCH 4/4] refactor(owie): prompt-as-md, slot injection, Langfuse SSOT, auto-push workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move system prompt from TS template literal to lib/chat/system-prompt.md - Add rule numbering (1.x, A/B/C/M/R/F/S.x) for easy reference - Slot system: {{personas}}, {{merchants}}, {{banks}} filled at push time - buildLocalPrompt() fills slots from live API — used by push script only - getSystemPrompt() fetches Langfuse (SSOT), falls back to buildLocalPrompt() - Remove unused REFUSAL_TEMPLATE export - Replace CT pronoun with bạn/mình throughout - Fix S.2 card-battle link: natural display text not raw slug path - push-prompt.ts: diff before push, skip if unchanged, emit ::pushed:: signal - push-prompt.yml: auto-push on every main push, Discord webhook on new version --- .github/workflows/push-prompt.yml | 59 +++++++++++++++++++++++++++++++ .gitignore | 1 + lib/chat/system-prompt.ts | 45 +++++++++++------------ scripts/push-prompt.ts | 43 ++++++++++++++++------ 4 files changed, 113 insertions(+), 35 deletions(-) create mode 100644 .github/workflows/push-prompt.yml diff --git a/.github/workflows/push-prompt.yml b/.github/workflows/push-prompt.yml new file mode 100644 index 00000000..7a768aec --- /dev/null +++ b/.github/workflows/push-prompt.yml @@ -0,0 +1,59 @@ +name: Sync Owie system prompt to Langfuse + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + push-prompt: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 11 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - name: Push prompt + id: push + run: | + output=$(pnpm push:prompt 2>&1) + echo "$output" + if echo "$output" | grep -q "::pushed::"; then + version=$(echo "$output" | grep "::pushed::" | sed 's/.*version=//') + echo "pushed=true" >> $GITHUB_OUTPUT + echo "version=$version" >> $GITHUB_OUTPUT + else + echo "pushed=false" >> $GITHUB_OUTPUT + fi + env: + LANGFUSE_PUBLIC_KEY: ${{ secrets.LANGFUSE_PUBLIC_KEY }} + LANGFUSE_SECRET_KEY: ${{ secrets.LANGFUSE_SECRET_KEY }} + LANGFUSE_BASE_URL: ${{ secrets.LANGFUSE_BASE_URL }} + OPENWALLET_API_KEY: ${{ secrets.OPENWALLET_API_KEY }} + + - name: Notify Discord + if: steps.push.outputs.pushed == 'true' + run: | + curl -s -X POST "${{ secrets.DISCORD_WEBHOOK_URL }}" \ + -H "Content-Type: application/json" \ + -d '{ + "embeds": [{ + "title": "🤖 Owie system prompt updated", + "description": "New version **${{ steps.push.outputs.version }}** pushed to Langfuse.", + "color": 5814783, + "fields": [ + { "name": "Triggered by", "value": "${{ github.actor }}", "inline": true }, + { "name": "Branch", "value": "${{ github.ref_name }}", "inline": true } + ], + "url": "${{ secrets.LANGFUSE_BASE_URL }}/prompts/chat-system-prompt" + }] + }' diff --git a/.gitignore b/.gitignore index b0eb886e..ded40026 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,4 @@ search-console-page-indexing/ storybook-static logs/ .claude/settings.local.json +.claude/docs/learnings/base-concepts/ diff --git a/lib/chat/system-prompt.ts b/lib/chat/system-prompt.ts index 06d0dfe4..742c765f 100644 --- a/lib/chat/system-prompt.ts +++ b/lib/chat/system-prompt.ts @@ -11,14 +11,23 @@ export const SYSTEM_PROMPT = fs.readFileSync( 'utf-8', ); +function applyPageContext(base: string, pageContext?: PageContext): string { + if (!pageContext) return base; + if (pageContext.type === 'card') { + return base + `\n\n## Ngữ cảnh trang hiện tại\nNgười dùng đang xem trang thẻ: **${pageContext.cardName}** (ngân hàng: ${pageContext.bankId}, mạng lưới: ${pageContext.cardNetwork}). Khi phù hợp, ưu tiên tư vấn về thẻ này. Dùng getCardDetail("${pageContext.cardId}") để lấy thông tin đầy đủ khi cần.`; + } + if (pageContext.type === 'bank') { + return base + `\n\n## Ngữ cảnh trang hiện tại\nNgười dùng đang xem trang ngân hàng: **${pageContext.bankName}** (id: ${pageContext.bankId}). Khi phù hợp, ưu tiên tư vấn về thẻ của ngân hàng này. Dùng searchCards với bank_id="${pageContext.bankId}" để lấy danh sách thẻ.`; + } + return base; +} + function buildSlots(): Record { const personas = Object.entries(PERSONA_UI_META) .filter(([, m]) => !m.hidden) .map(([slug, m]) => `- ${slug}: ${m.name}: ${m.description} (page: /linh-vuc/${m.slug})`) .join('\n'); - const merchants = Object.keys(INTENT_ICON).join(', '); - return { personas, merchants }; } @@ -29,29 +38,17 @@ function fillSlots(text: string, slots: Record): string { ); } -function applyPageContext(base: string, pageContext?: PageContext): string { - if (!pageContext) return base; - if (pageContext.type === 'card') { - return base + `\n\n## Ngữ cảnh trang hiện tại\nNgười dùng đang xem trang thẻ: **${pageContext.cardName}** (ngân hàng: ${pageContext.bankId}, mạng lưới: ${pageContext.cardNetwork}). Khi phù hợp, ưu tiên tư vấn về thẻ này. Dùng getCardDetail("${pageContext.cardId}") để lấy thông tin đầy đủ khi cần.`; - } - if (pageContext.type === 'bank') { - return base + `\n\n## Ngữ cảnh trang hiện tại\nNgười dùng đang xem trang ngân hàng: **${pageContext.bankName}** (id: ${pageContext.bankId}). Khi phù hợp, ưu tiên tư vấn về thẻ của ngân hàng này. Dùng searchCards với bank_id="${pageContext.bankId}" để lấy danh sách thẻ.`; - } - return base; -} - -/** SSOT for the full system prompt sent to the LLM. */ -export async function getSystemPrompt(pageContext?: PageContext): Promise<{ text: string; version: number }> { - const [{ text: langfuseText, version }, banks] = await Promise.all([ - fetchSystemPrompt(), - getBanks().catch(() => []), - ]); - +/** Build full prompt from local .md — used only by push-prompt script. */ +export async function buildLocalPrompt(): Promise { + const banks = await getBanks().catch(() => []); const bankList = banks.map((b: { id: string; name: string }) => `- ${b.id}: ${b.name}`).join('\n'); const slots = { ...buildSlots(), banks: bankList }; + return fillSlots(SYSTEM_PROMPT, slots); +} - const baseText = langfuseText || SYSTEM_PROMPT; - const filled = fillSlots(baseText, slots); - - return { text: applyPageContext(filled, pageContext), version }; +/** Fetch prompt from Langfuse (SSOT). Falls back to local build if Langfuse unavailable. Page context appended per-request only. */ +export async function getSystemPrompt(pageContext?: PageContext): Promise<{ text: string; version: number }> { + const { text: langfuseText, version } = await fetchSystemPrompt(); + const text = langfuseText || await buildLocalPrompt(); + return { text: applyPageContext(text, pageContext), version }; } diff --git a/scripts/push-prompt.ts b/scripts/push-prompt.ts index ddc6a2d7..5fb13c7a 100644 --- a/scripts/push-prompt.ts +++ b/scripts/push-prompt.ts @@ -1,9 +1,10 @@ /** - * Push full system prompt → Langfuse as 'chat-system-prompt' (label: production). - * Uses getSystemPrompt() - same SSOT as the chat route - so Langfuse reflects exactly what the LLM receives. + * Push system prompt → Langfuse as 'chat-system-prompt' (label: production). + * Builds full prompt via buildLocalPrompt() (slots filled with live data), compares + * with current Langfuse version, and skips push if unchanged. * Run: pnpm push:prompt * - * Requires LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY, LANGFUSE_BASE_URL in .env.local + * Requires LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY, LANGFUSE_BASE_URL in env */ import * as dotenv from 'dotenv'; import * as path from 'node:path'; @@ -15,21 +16,39 @@ const LANGFUSE_SECRET_KEY = process.env.LANGFUSE_SECRET_KEY ?? ''; const LANGFUSE_BASE_URL = process.env.LANGFUSE_BASE_URL ?? 'https://cloud.langfuse.com'; if (!LANGFUSE_PUBLIC_KEY || !LANGFUSE_SECRET_KEY) { - console.error('Missing LANGFUSE_PUBLIC_KEY or LANGFUSE_SECRET_KEY in .env.local'); + console.error('Missing LANGFUSE_PUBLIC_KEY or LANGFUSE_SECRET_KEY in env'); process.exit(1); } const auth = 'Basic ' + Buffer.from(`${LANGFUSE_PUBLIC_KEY}:${LANGFUSE_SECRET_KEY}`).toString('base64'); +async function fetchCurrentPrompt(): Promise { + try { + const res = await fetch( + `${LANGFUSE_BASE_URL}/api/public/v2/prompts/chat-system-prompt?label=production`, + { headers: { Authorization: auth } }, + ); + if (!res.ok) return null; + const data = await res.json() as { prompt: string }; + return data.prompt; + } catch { + return null; + } +} + async function main() { - // Push only the base SYSTEM_PROMPT — static lists (personas, merchants, banks) are - // appended at runtime by getSystemPrompt() to stay in sync with live data. - // Pushing the assembled version causes duplicates when getSystemPrompt() appends again. - const { SYSTEM_PROMPT } = await import('@/lib/chat/system-prompt'); - const promptText = SYSTEM_PROMPT; + const { buildLocalPrompt } = await import('@/lib/chat/system-prompt'); + const promptText = await buildLocalPrompt(); + + console.log(`Built prompt: ${promptText.length} chars`); + + const current = await fetchCurrentPrompt(); + if (current === promptText) { + console.log('✓ Prompt unchanged, skipping push'); + return; + } - console.log(`Pushing chat-system-prompt to ${LANGFUSE_BASE_URL}`); - console.log(`Length: ${promptText.length} chars`); + console.log(current === null ? 'No existing prompt found, pushing...' : 'Prompt changed, pushing...'); const res = await fetch(`${LANGFUSE_BASE_URL}/api/public/v2/prompts`, { method: 'POST', @@ -49,6 +68,8 @@ async function main() { console.log(`✓ Pushed version ${body.version} (id: ${body.id})`); console.log(` Labels: ${body.labels?.join(', ')}`); console.log(` View: ${LANGFUSE_BASE_URL}/prompts/chat-system-prompt`); + // Signal to CI that a new version was pushed + console.log(`::pushed::version=${body.version}`); } else { console.error(`✗ Failed ${res.status}: ${JSON.stringify(body)}`); process.exit(1);