From 15dec6c3fc6210ea823c1d3eb82a5ecad2056618 Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 6 Jun 2026 15:38:10 +0700 Subject: [PATCH] 690606:1538 ADR-035-135 #05 --- .../ai/processors/ai-batch.processor.spec.ts | 64 ++++++ .../ai/processors/ai-batch.processor.ts | 194 ++++++++++-------- memory/agent-memory.md | 1 + .../2026-06-06-add-ai-prompts-public-id.sql | 23 +++ specs/88-logs/_backend_logs.md | Bin 23691 -> 7860 bytes 5 files changed, 195 insertions(+), 87 deletions(-) create mode 100644 specs/03-Data-and-Storage/deltas/2026-06-06-add-ai-prompts-public-id.sql diff --git a/backend/src/modules/ai/processors/ai-batch.processor.spec.ts b/backend/src/modules/ai/processors/ai-batch.processor.spec.ts index 27f263ec..a85fe681 100644 --- a/backend/src/modules/ai/processors/ai-batch.processor.spec.ts +++ b/backend/src/modules/ai/processors/ai-batch.processor.spec.ts @@ -84,6 +84,7 @@ describe('AiBatchProcessor', () => { }; const mockRedis = { setex: jest.fn().mockResolvedValue('OK'), + get: jest.fn().mockResolvedValue(null), }; const mockAttachmentRepo = { findOne: jest.fn().mockResolvedValue({ @@ -143,6 +144,7 @@ describe('AiBatchProcessor', () => { resolvedPrompt: 'Resolved test prompt with OCR text', versionNumber: 2, }), + findByVersion: jest.fn().mockResolvedValue(null), saveTestResult: jest.fn().mockResolvedValue(undefined), }; beforeEach(async () => { @@ -315,6 +317,68 @@ describe('AiBatchProcessor', () => { expect.stringContaining('completed') ); }); + it('sandbox-ai-extract ควร regenerate response ใหม่เมื่อ parse JSON ครั้งแรกล้มเหลว', async () => { + const cachedOcrPayload = { + ocrText: 'OCR text for retry test\u0002\u0000', + ocrUsed: true, + engineUsed: 'typhoon-np-dms-ocr', + fallbackUsed: false, + timestamp: '2026-06-06T15:00:00.000Z', + }; + mockRedis.get = jest + .fn() + .mockResolvedValueOnce(JSON.stringify(cachedOcrPayload)); + mockAiPromptsService.findByVersion = jest.fn().mockResolvedValue({ + id: 1, + promptType: 'ocr_extraction', + versionNumber: 2, + template: + 'Resolved test prompt with OCR text {{ocr_text}} and context {{master_data_context}}', + isActive: true, + contextConfig: { filter: {} }, + }); + mockOllamaService.generate + .mockResolvedValueOnce('{\u0002\u0000') + .mockResolvedValueOnce( + JSON.stringify({ + subject: 'Recovered after retry', + confidence: 0.91, + tags: ['retry'], + }) + ); + const job = { + id: 'job-ai-extract-retry', + data: { + jobType: 'sandbox-ai-extract', + documentPublicId: 'idem-ai-extract-123', + projectPublicId: 'default', + payload: { promptVersion: 2 }, + idempotencyKey: 'idem-ai-extract-123', + }, + } as unknown as Job; + await processor.process(job); + expect(mockOllamaService.generate).toHaveBeenCalledTimes(2); + expect(mockOllamaService.generate).toHaveBeenNthCalledWith( + 1, + expect.not.stringContaining('\u0002'), + expect.objectContaining({ + timeoutMs: 120000, + }) + ); + expect(mockAiPromptsService.saveTestResult).toHaveBeenCalledWith( + 'ocr_extraction', + 2, + expect.objectContaining({ + subject: 'Recovered after retry', + confidence: 0.91, + }) + ); + expect(mockRedis.setex).toHaveBeenLastCalledWith( + 'ai:rag:result:idem-ai-extract-123', + 3600, + expect.stringContaining('"llmPrompt"') + ); + }); it('EC-001: ควรบันทึก aiIssues เมื่อ AI สกัด Tag ใหม่ที่ไม่มีในระบบ', async () => { mockTagsService.findOrSuggestTags.mockResolvedValueOnce([ { diff --git a/backend/src/modules/ai/processors/ai-batch.processor.ts b/backend/src/modules/ai/processors/ai-batch.processor.ts index c17a2aab..e3e00554 100644 --- a/backend/src/modules/ai/processors/ai-batch.processor.ts +++ b/backend/src/modules/ai/processors/ai-batch.processor.ts @@ -78,6 +78,21 @@ export interface AiBatchJobData { /** OCR text สูงสุดที่ส่งเข้า LLM prompt — ป้องกัน context overflow (num_ctx 8192, Thai ~3 chars/token) */ const MAX_OCR_TEXT_CHARS = 15000; +const MAX_JSON_PARSE_ATTEMPTS = 2; +const removeControlCharacters = ( + value: string, + includeDeleteCharacter = false +): string => + Array.from(value) + .filter((character) => { + const code = character.charCodeAt(0); + const isAsciiControl = + (code >= 0 && code <= 8) || code === 11 || code === 12; + const isAdditionalControl = code >= 14 && code <= 31; + const isDeleteCharacter = includeDeleteCharacter && code === 127; + return !isAsciiControl && !isAdditionalControl && !isDeleteCharacter; + }) + .join(''); const readString = (value: unknown): string | undefined => typeof value === 'string' && value.trim().length > 0 ? value : undefined; @@ -145,6 +160,14 @@ const parseMigrateDocumentMetadata = ( }; }; +const sanitizeLlmJsonResponse = (response: string): string => + removeControlCharacters( + response.replace(/```json/g, '').replace(/```/g, '') + ).trim(); + +const sanitizeOcrText = (text: string): string => + removeControlCharacters(text.replace(/\r\n/g, '\n'), true).trim(); + /** Processor สำหรับงาน AI batch ที่รันทีละงานเพื่อคุม VRAM * lockDuration: 150000ms — รองรับ Ollama sandbox ที่ใช้เวลาสูงสุด 120s (ADR-029 FR-008) * ค่า default ของ BullMQ คือ 30000ms ซึ่งน้อยกว่า timeout → job stall @@ -174,6 +197,51 @@ export class AiBatchProcessor extends WorkerHost { super(); } + /** เรียก LLM แล้ว parse JSON แบบ retry จริงเมื่อได้ผลลัพธ์ไม่สมบูรณ์ */ + private async generateStructuredJson( + prompt: string, + options: { timeoutMs: number; model?: string; system?: string } + ): Promise<{ + extractedMetadata: Record; + rawResponse: string; + cleanedResponse: string; + }> { + let lastRawResponse = ''; + let lastCleanedResponse = ''; + for (let attempt = 1; attempt <= MAX_JSON_PARSE_ATTEMPTS; attempt += 1) { + const rawResponse = await this.ollamaService.generate(prompt, options); + const cleanedResponse = sanitizeLlmJsonResponse(rawResponse); + lastRawResponse = rawResponse; + lastCleanedResponse = cleanedResponse; + this.logger.debug(`Raw LLM response: ${rawResponse}`); + try { + return { + extractedMetadata: JSON.parse(cleanedResponse) as Record< + string, + unknown + >, + rawResponse, + cleanedResponse, + }; + } catch { + if (attempt >= MAX_JSON_PARSE_ATTEMPTS) { + this.logger.error( + `Failed to parse LLM response as JSON after ${MAX_JSON_PARSE_ATTEMPTS} attempts. Raw: ${lastRawResponse}, Cleaned: ${lastCleanedResponse}` + ); + throw new Error( + `Failed to parse LLM response as JSON after ${MAX_JSON_PARSE_ATTEMPTS} attempts. Raw: ${lastRawResponse.substring(0, 200)}, Cleaned: ${lastCleanedResponse.substring(0, 200)}` + ); + } + this.logger.warn( + `JSON parse attempt ${attempt} failed, regenerating response...` + ); + } + } + throw new Error( + `Failed to parse LLM response as JSON after ${MAX_JSON_PARSE_ATTEMPTS} attempts` + ); + } + /** Dispatch งาน batch ตาม jobType */ async process(job: Job): Promise { const isSandbox = @@ -410,6 +478,12 @@ export class AiBatchProcessor extends WorkerHost { pdfPath, engineType ); + const sanitizedOcrText = sanitizeOcrText(ocrResult.text); + if (sanitizedOcrText.length !== ocrResult.text.length) { + this.logger.warn( + `OCR text sanitized before LLM: raw=${ocrResult.text.length} chars, sanitized=${sanitizedOcrText.length} chars` + ); + } const activePrompt = await this.aiPromptsService.getActive('ocr_extraction'); @@ -426,12 +500,12 @@ export class AiBatchProcessor extends WorkerHost { ); const ocrTextSafe = - ocrResult.text.length > MAX_OCR_TEXT_CHARS + sanitizedOcrText.length > MAX_OCR_TEXT_CHARS ? (this.logger.warn( - `OCR text truncated: ${ocrResult.text.length} chars > ${MAX_OCR_TEXT_CHARS} limit (context overflow protection)` + `OCR text truncated: ${sanitizedOcrText.length} chars > ${MAX_OCR_TEXT_CHARS} limit (context overflow protection)` ), - ocrResult.text.substring(0, MAX_OCR_TEXT_CHARS)) - : ocrResult.text; + sanitizedOcrText.substring(0, MAX_OCR_TEXT_CHARS)) + : sanitizedOcrText; const resolvedPrompt = activePrompt.template .replace('{{ocr_text}}', ocrTextSafe) @@ -444,45 +518,12 @@ export class AiBatchProcessor extends WorkerHost { `Prompt stats: OCR=${ocrTextSafe.length} chars, MasterData=${JSON.stringify(masterDataContext, null, 2).length} chars, Total=${resolvedPrompt.length} chars` ); - const response = await this.ollamaService.generate(resolvedPrompt, { - timeoutMs: 120000, - }); - this.logger.debug(`Raw LLM response: ${response}`); - const cleanedResponse = response - .replace(/```json/g, '') - .replace(/```/g, '') - .trim(); - let extractedMetadata: Record | null = null; - let parseAttempts = 0; - const maxParseAttempts = 2; - while (parseAttempts < maxParseAttempts) { - try { - extractedMetadata = JSON.parse(cleanedResponse) as Record< - string, - unknown - >; - break; - } catch { - parseAttempts++; - if (parseAttempts >= maxParseAttempts) { - this.logger.error( - `Failed to parse LLM response as JSON after ${maxParseAttempts} attempts. Raw: ${response}, Cleaned: ${cleanedResponse}` - ); - throw new Error( - `Failed to parse LLM response as JSON after ${maxParseAttempts} attempts. Raw: ${response.substring(0, 200)}, Cleaned: ${cleanedResponse.substring(0, 200)}` - ); - } - this.logger.warn( - `JSON parse attempt ${parseAttempts} failed, retrying...` - ); - await new Promise((resolve) => setTimeout(resolve, 1000)); + const { extractedMetadata } = await this.generateStructuredJson( + resolvedPrompt, + { + timeoutMs: 120000, } - } - if (!extractedMetadata) { - throw new Error( - `Failed to parse LLM response as JSON after ${maxParseAttempts} attempts` - ); - } + ); await this.aiPromptsService.saveTestResult( 'ocr_extraction', activePrompt.versionNumber, @@ -495,7 +536,7 @@ export class AiBatchProcessor extends WorkerHost { requestPublicId: idempotencyKey, status: 'completed', answer: JSON.stringify(extractedMetadata, null, 2), - ocrText: ocrResult.text, + ocrText: sanitizedOcrText, ocrUsed: ocrResult.ocrUsed, engineUsed: ocrResult.engineUsed, fallbackUsed: ocrResult.fallbackUsed, @@ -549,13 +590,19 @@ export class AiBatchProcessor extends WorkerHost { engineType, typhoonOptions ); + const sanitizedOcrText = sanitizeOcrText(ocrResult.text); + if (sanitizedOcrText.length !== ocrResult.text.length) { + this.logger.warn( + `OCR text sanitized before cache: raw=${ocrResult.text.length} chars, sanitized=${sanitizedOcrText.length} chars` + ); + } // Cache OCR text สำหรับ Step 2 await this.redis.setex( `ai:sandbox:ocr:${idempotencyKey}`, 3600, JSON.stringify({ - ocrText: ocrResult.text, + ocrText: sanitizedOcrText, ocrUsed: ocrResult.ocrUsed, engineUsed: ocrResult.engineUsed, fallbackUsed: ocrResult.fallbackUsed, @@ -569,7 +616,7 @@ export class AiBatchProcessor extends WorkerHost { JSON.stringify({ requestPublicId: idempotencyKey, status: 'completed', - ocrText: ocrResult.text, + ocrText: sanitizedOcrText, ocrUsed: ocrResult.ocrUsed, engineUsed: ocrResult.engineUsed, fallbackUsed: ocrResult.fallbackUsed, @@ -624,7 +671,12 @@ export class AiBatchProcessor extends WorkerHost { fallbackUsed?: boolean; timestamp: string; }; - const { ocrText } = parsedOcr; + const ocrText = sanitizeOcrText(parsedOcr.ocrText); + if (ocrText.length !== parsedOcr.ocrText.length) { + this.logger.warn( + `Cached OCR text sanitized before AI extraction: raw=${parsedOcr.ocrText.length} chars, sanitized=${ocrText.length} chars` + ); + } // ดึง prompt version const activePrompt = @@ -667,48 +719,15 @@ export class AiBatchProcessor extends WorkerHost { '{{master_data_context}}', JSON.stringify(masterDataContext, null, 2) ); - - const response = await this.ollamaService.generate(resolvedPrompt, { - timeoutMs: 120000, - }); - - this.logger.debug(`Raw LLM response: ${response}`); - const cleanedResponse = response - .replace(/```json/g, '') - .replace(/```/g, '') - .trim(); - - let extractedMetadata: Record | null = null; - let parseAttempts = 0; - const maxParseAttempts = 2; - while (parseAttempts < maxParseAttempts) { - try { - extractedMetadata = JSON.parse(cleanedResponse) as Record< - string, - unknown - >; - break; - } catch { - parseAttempts++; - if (parseAttempts >= maxParseAttempts) { - this.logger.error( - `Failed to parse LLM response as JSON after ${maxParseAttempts} attempts. Raw: ${response}, Cleaned: ${cleanedResponse}` - ); - throw new Error( - `Failed to parse LLM response as JSON after ${maxParseAttempts} attempts. Raw: ${response.substring(0, 200)}, Cleaned: ${cleanedResponse.substring(0, 200)}` - ); - } - this.logger.warn( - `JSON parse attempt ${parseAttempts} failed, retrying...` - ); - await new Promise((resolve) => setTimeout(resolve, 1000)); + this.logger.debug( + `Prompt stats: OCR=${ocrTextSafe.length} chars, MasterData=${JSON.stringify(masterDataContext, null, 2).length} chars, Total=${resolvedPrompt.length} chars` + ); + const { extractedMetadata } = await this.generateStructuredJson( + resolvedPrompt, + { + timeoutMs: 120000, } - } - if (!extractedMetadata) { - throw new Error( - `Failed to parse LLM response as JSON after ${maxParseAttempts} attempts` - ); - } + ); await this.aiPromptsService.saveTestResult( 'ocr_extraction', @@ -728,6 +747,7 @@ export class AiBatchProcessor extends WorkerHost { engineUsed: parsedOcr.engineUsed, fallbackUsed: parsedOcr.fallbackUsed, promptVersionUsed: targetPrompt.versionNumber, + llmPrompt: resolvedPrompt, completedAt: new Date().toISOString(), }) ); diff --git a/memory/agent-memory.md b/memory/agent-memory.md index 22692b9c..448d2225 100644 --- a/memory/agent-memory.md +++ b/memory/agent-memory.md @@ -20,6 +20,7 @@ - 2026-06-05 (Session 15): Feature 234 RAG Pipeline สมบูรณ์ — implement BGE-M3 embedding (dense 1024 + sparse), BGE-Reranker-Large, Semantic Chunking (typhoon2.5 + `` tags + fallback), Hybrid Qdrant schema (drop+recreate), workflow hook `syncStatus()` → `enqueueRagPrepare()`, processRagPrepare pipeline ใน ai-batch.processor; แก้ CRITICAL 2 ประเด็นจาก speckit-analyze; ผ่าน speckit-tester (19/19 tests), speckit-validate (15/15 FR, ทุก SC); ปิด Gap ทั้ง 2 รายการ (jobId dedup confirmed + integration test 9 tests); สร้าง validation-report.md ใน specs/200-fullstacks/234-rag-pipeline-enhancements/ - 2026-06-06: เพิ่ม MCP MariaDB Tools section ใน memory/agent-memory.md, AGENTS.md, และ rule files (.agents/rules/08-development-flow.md, .devin/rules/08-development-flow.md) — รวม 8 tools (test_connection, show_databases, show_tables, describe_table, query, insert, update, delete), การใช้งานร่วมกับ Development Flow, และข้อควรระวังเรื่อง DDL operations - 2026-06-06: เพิ่ม MCP Memory Tools section ใน memory/agent-memory.md — รวม 9 tools (create_entities, create_relations, add_observations, delete_entities, delete_relations, delete_observations, open_nodes, read_graph, search_nodes), การใช้งานร่วมกับ Development Flow สำหรับจัดการ Knowledge Graph และ Long-term Memory +- 2026-06-06 (Session 15): LLM JSON Parse Failure & VRAM Fix (ADR-035-135) — แก้ปัญหา JSON parse failure ใน Sandbox AI Extraction โดยเพิ่ม retry logic (2 attempts) + enhanced error logging ใน ai-batch.processor.ts, เพิ่ม system prompt support ใน ollama.service.ts, แก้ VRAM contention โดยเปลี่ยน keep_alive=0 ใน ocr-sidecar/app.py (Desk-5439), แก้ ESLint seg fault โดยลด heap size 8192 → 4096 ใน backend/package.json, แก้ Schema mismatch โดยสร้าง delta SQL 2026-06-06-add-ai-prompts-public-id.sql เพิ่ม public_id และ context_config columns ใน ai_prompts table, เพิ่ม prompt stats log + llmPrompt field ใน Sandbox Result, เพิ่ม LLM Prompt UI card (สีม่วง) ใน OcrSandboxPromptManager.tsx --> # 🧠 Agent Long-term Project Memory diff --git a/specs/03-Data-and-Storage/deltas/2026-06-06-add-ai-prompts-public-id.sql b/specs/03-Data-and-Storage/deltas/2026-06-06-add-ai-prompts-public-id.sql new file mode 100644 index 00000000..fdf95e7f --- /dev/null +++ b/specs/03-Data-and-Storage/deltas/2026-06-06-add-ai-prompts-public-id.sql @@ -0,0 +1,23 @@ +-- Delta: เพิ่ม public_id และ context_config columns ใน ai_prompts +-- Date: 2026-06-06 +-- Related ADR: ADR-019 (UUID strategy), ADR-029 (Dynamic Prompts) +-- ------------------------------------------------------------ +-- การเปลี่ยนแปลงโครงสร้างฐานข้อมูล (Schema changes) +-- ------------------------------------------------------------ + +-- เพิ่ม public_id column (UUIDv7) สำหรับ ADR-019 compliance +ALTER TABLE ai_prompts +ADD COLUMN public_id UUID UNIQUE COMMENT 'Public UUID สำหรับ API (ADR-019)'; + +-- เพิ่ม context_config column สำหรับ ADR-029 context filtering +ALTER TABLE ai_prompts +ADD COLUMN context_config JSON NULL COMMENT 'Configuration สำหรับ Master Data context filtering (project/contract scope)'; + +-- สร้าง UUID สำหรับ records ที่มีอยู่แล้ว +UPDATE ai_prompts +SET public_id = UUID() +WHERE public_id IS NULL; + +-- ตั้ง public_id เป็น NOT NULL หลังจาก populate ครบแล้ว +ALTER TABLE ai_prompts +MODIFY COLUMN public_id UUID NOT NULL; diff --git a/specs/88-logs/_backend_logs.md b/specs/88-logs/_backend_logs.md index 49c8af6fd77d7c559c9b49e81840e2d440a825c6..b436337b87200fc6e9230282a4ce7a7afc27a321 100644 GIT binary patch literal 7860 zcmeHM+fLj@7_NGW#5;`kDpB!ad|W$P36hXB4JQE+7b%Ly_HWj~>v6_o*lkp)SABr0 zPnaj^@5AnT3CofMlvIKs#f-=Q`7r-CZ}*^(r-Txs5Z%%6*U$}TgBZST`6eX?yQKQr z*?&piKIL2%Qn37#Y~^{HgiIzpBS}F-nPo|KMtJ6vp_I9=Y1D949sg0^)OB5Bd9uFt z_Uo7(U9z(?5ai9PiWj?JNy>s02$RdWMLaBSWM})9yP(^PB!wf%1v`Ty5En@Zr{p2bd&cdh#+#A6^HScX}Xm>WilKd2p&RFaB)hGSQZ6*O!l80D*IVFA@6xW^5%V|=BK~@ zt(Inb0VuZN>5A*JQ1Jq4C?+-CxbFp^>(-Y^)AHUA#ddaE@fA(v~7^9rrRRLq!rn$OOjXEd4w~rA}X^D6Fpgeb=!yw2#sl60karKo zv7}?ZOYDj<_8N#bs^Tvu7(=S6uHxf0qHiA_?jK&q=S5ADNZvB*szF#mYB^XevEdn0 zNeY#q#F;?m-+bHalA4tl&UDZ9Q5pLR#jwd+`il%od zESajXL9qLmRX;vhY{#qG*0vB_e6!|1|5X31uPr*NIz(Z0z8cAD+h$u&Y$em|dQ`Ws z2oD0@`(^@XxpCrFteV-8mxsyL~s;W6QBO=rT)ih@l~H)qYuVnE;}87i_T1wH z#(5&}R94l?u0>;BX3-6$@=MJ4e_^xO+plK&{Fr_JJ+!fIE6?5Z&;^qB*h3pW_K(m* zZzsz4?xDAn@}Y-5^w6d7OC3VA&AArq`R?V5I(In&G%B_v@4I}_7FzngBS53_Dp7uj z^0m@n59RBjeBE5WjA{95#`~8k4_LZA4xxr}a16kC`jpfo9^qkF3Ou#0ujLg|sHu=$ z;4nUW9DyA2Xz+6TxVw7&h9nBh&-ru3pdF*h!La;1AY@OjSJJ{uaBx%Yt z9HPjhlO&7yWkL3i2_k&@6M_ql^@NNa>v0Xv!wCeh6RFvztC|jZ^!nH1-JK1RCg(t2 zBJRu|lV?N3=Rl)Y1Jvt?1IJ7x(@#3$bsjsCc2+x&oefu1>1^secJ_BxJCB_WR|tMT z+rTui5E{mT^g{kgCHCUlw?6aDLr~KDoO4 z_pBY~D-0Yj0M6UNePNbvhV9J^?{nT4zl2=CCPgS(rQW;w;%LOP*>XK?D~S7KMa;zr z#HOy=Dt*_iU$8WZ7+#6sDy{`Uo3Nv>ift@+X<(9)VB@}aijQ+IATDU#8FfH)a{0|zWAFs`&PEu?PFsW0=( zN||RA4bSzxnPFNK>wpP1D(*sHyN?1JbAdBNGw*_~;Zfh7ldW$$8p!OL%nNulQEL2H z)NZ7UDbabYRlclzP(p+#sNEd9&1=+L_jJG|vUTy0LiX5H_yzW;FT Q-Eb{dpH^?x#MjsU1LK#YsQ>@~ literal 23691 zcmeHP+fL(379Gta68})jQ=`V0@-_BRBxnxJ>1k*fpw$mO2$fwGVsLDqDhJ54jxc8umg$xNCt0=juG?OVvcG;}B0obIAqDA<2LBD+a5fS4b=x;Q zbaa4
    V4^z}HM|p3q)eh2%RE!8;M+?nmM<(J#}0M-+*0 z#Kr`@!Mb4~8jZyU`u$)3MdB*TGRO#m_X0*i`-NP03K&RzMy7E}D2fuwZka&Cl%qM; z&ugdXp6x7)qp&1-hKdUadbzpM%62?OA}73H@z!&e_U=OV-=%3Ta!#@{)M^k#0`WKH!f*>2x}Yp&-Slp3x@!r>6!MMB;3%z!bizFpsaem2(y?DaA_7-pD9j1#EPqXmf|^-D7N9~1c$ce+4y;E z>b4I!6oUvr(ERN|GsbER;0lJ(SVm z5y?k`9t^omt2$3;MNet2X;@}p>Pldmo?;QpRy^W)3e)>RVCYU@8oCOf@1Yv_Fe|3L z7<}s$pJ$xF2w*)Q2K8nyPqU-?N6s>s*+(oPaX#(&SjE3dO2T7-{{HX(lsPGSNJNx{ zgu_^zO+U|2vC~&Ekkxg+?>e>y`BAEbNTOFv8kiTbmK}a9I|f_2t(zDH)4W*NcsfQe z=?6(PPpZ8Q<>@R@#gML(dv_3_^mY2(^6mNU?7_i*QuilkB7*_Qn*3IjKMO|hAN
    C=Fj$>#^L>^KT+4PJ&7|$?{kg^P;U!}GgVM~$*IOiPA3HUlFWbkT(AT{<4M@Cu z8?v%vVAw%P1LXzhuyv+kIDrkFuCHaqyf5n-5^02vqeK(c(xw4fty+{HU%X&*emTaN z<)f4ippudTCz(=|4X|sdh6kIofme~CjDTfewEA%&X%oTt_62PDgM;*+Q5+M^R(15| z3zT%}jXB0UcilZxr_MjR_ zH_*-qR!yd1t6!E+I_%Cv!lULRyY!;;{dbzU2jd?}A-bUin-SWZfV5U~`0!1A4T`lHOZ28JQInLi^KnWiF%z1a zCD~ZpN`TX)cw2n3jBLwMEmc2nu7~M(oF-csDoqu8=OM^!p+J?K2+)#a#8ryLR8Bwyg(d$jpA=4fSD2v^g$yYub#3MnOb>|5d#x!@Cqd zwp<_E8?yw|+CiKp{MV!L8|wKs^1}H8QtCVm7#;?$9S&XWkdXEJAmv%#91>Hfx?|lxg$fp0IbB5kO#wHo z3J2zoVfffxjJnmo?~~;{yh4GN?jhHzZwh!^;iS;fMaYFFQHvGBw0-l=2w%1y{<&hb z4By1FHL+U1{eb z-$(s@30W8tRb%Zi5D}>4L58q9BBNmL>@tvVo-=l96!rK+|JFNIcCvrr1(r2GnY zdkDz3UEeg8&AGMSEUrq07!P=?Z!fze_p15O4;i_(e;XcrG=z*meIT`)mcWovy5KNg6k}!%6zS=-Oe)VEThsw?<}L|{Dt6! z4(Gh__e{&^Df(@h25gQ#b{eqLfU9u)V?`@s8Fjx5jFxtg0Ve~aMQNe)WT4ByXfc!l zCj+BJX(4Hy2JAFocWj_Ip7BQUX`PAH znOL2P)tOkYd>2)B?+e^JR~_HzOsvkts&0Y&KW}w>-GdBE3@EcPpk82NnYHs$KgDVB z^#1pUw){a)WH4)0D$&GhAYa?WGVPTyv6fO<2@`86%g)5=Osq$q3kN1vGlc57-95nN zrO%~#Se?qyzA`Xc3}wK{z-UogQAsZ&N*NffOv!+gfzhJ0(0MY@Wni=z%7BxB(W11F zw9drpOsvkt>P)Q8#Ofa4vg}DU^Y?Ld4{#};>h+^Nz~xaB%YkQlJ>eL_`r7{hU3!Y$