diff --git a/mcp_backend/src/api/__tests__/ip-objects-tools.test.ts b/mcp_backend/src/api/__tests__/ip-objects-tools.test.ts index 2511d5c2..dbdf953e 100644 --- a/mcp_backend/src/api/__tests__/ip-objects-tools.test.ts +++ b/mcp_backend/src/api/__tests__/ip-objects-tools.test.ts @@ -209,5 +209,95 @@ describe('IpObjectsTools', () => { // enrichment tools were orchestrated via the registry const called = registry.executeTool.mock.calls.map((c: any[]) => c[0]); expect(called).toEqual(expect.arrayContaining(['search_court_decisions', 'get_legislation_section'])); + // legal entity: owner check via EDRPOU, no skip marker + expect(called).toContain('openreyestr_get_by_edrpou'); + expect(p.owner_check).toEqual({ ok: true }); + }); + + // A dossier DB mock: disambiguation + card (customisable) + no events/collisions. + function makeDossierDb(cardRow: any) { + return makeDb((sql) => { + if (sql.includes('ip_object_events')) return { rows: [] }; + if (sql.includes('similarity(title_ua')) return { rows: [] }; + if (sql.includes('obj_type = 4 ORDER BY obj_state')) return { rows: [cardRow] }; + return { rows: [{ obj_type: 4, obj_type_name: 'Торговельні марки', obj_state: 2, + app_number: cardRow.app_number, registration_number: cardRow.registration_number, + title_ua: cardRow.title_ua, owner_name: cardRow.owner_name, status: 'green' }] }; + }); + } + + it('get_trademark_dossier marks owner_check as service_unavailable when the EDRPOU lookup fails', async () => { + const db = makeDossierDb({ ...tmRow, obj_state: 2, registration_number: '67482', raw_data: {} }); + const registry = { executeTool: jest.fn(async (name: string) => + name === 'openreyestr_get_by_edrpou' + ? { isError: true, content: [{ type: 'text', text: 'down' }] } + : { content: [{ type: 'text', text: '{"ok":true}' }] }) }; + const tool = new IpObjectsTools(db, registry); + const p = parse(await tool.executeTool('get_trademark_dossier', { number: '67482' })); + expect(p.owner_check).toEqual({ skipped: true, reason: 'service_unavailable' }); + }); + + it('get_trademark_dossier fans out by name for an individual owner (no EDRPOU)', async () => { + const individual = { + ...tmRow, obj_state: 2, registration_number: '67482', + owner_name: 'Дьяконенко Олександр Євгенович', owner_edrpou: null, owner_kind: 'individual', + raw_data: { HolderDetails: { Holder: [{ HolderAddressBook: { FormattedNameAddress: { + Address: { AddressCountryCode: 'UA', FreeFormatAddress: { FreeFormatAddressLine: 'вул. Тестова, 1, м. Київ' } }, + } } }] } }, + }; + const db = makeDossierDb(individual); + const registry = { executeTool: jest.fn(async (name: string) => + name === 'openreyestr_search_debtors' + ? { content: [{ type: 'text', text: '{"results":[{"debtor_name":"Дьяконенко О.Є."}]}' }] } + : { content: [{ type: 'text', text: '{"results":[]}' }] }) }; + const tool = new IpObjectsTools(db, registry); + const p = parse(await tool.executeTool('get_trademark_dossier', { number: '67482' })); + + const check = p.owner_check; + expect(check.skipped).toBeUndefined(); + expect(check.match_basis).toBe('name_only'); + expect(check.owner_address).toBe('вул. Тестова, 1, м. Київ'); + expect(check.probable_matches.debtors.results[0].debtor_name).toBe('Дьяконенко О.Є.'); + expect(Object.keys(check.probable_matches)).toEqual(expect.arrayContaining([ + 'debtors', 'enforcement_proceedings', 'bankruptcy_cases', 'sanctions', + 'business_entities', 'ip_portfolio', 'court_hearings', + ])); + + const calls = registry.executeTool.mock.calls; + const byTool = (n: string) => calls.filter((c: any[]) => c[0] === n).map((c: any[]) => c[1]); + expect(calls.map((c: any[]) => c[0])).not.toContain('openreyestr_get_by_edrpou'); + expect(byTool('openreyestr_search_debtors')[0]).toMatchObject({ query: 'Дьяконенко Олександр Євгенович' }); + expect(byTool('search_registry')[0]).toMatchObject({ registry: 'sanctions', filters: { name: 'Дьяконенко Олександр Євгенович' } }); + expect(byTool('search_ip_objects')[0]).toMatchObject({ owner: 'Дьяконенко Олександр Євгенович' }); + expect(byTool('search_court_hearing_schedule')[0]).toMatchObject({ participant: 'Дьяконенко Олександр Євгенович' }); + // volume capped per source + for (const [name, args] of calls as unknown as Array<[string, any]>) { + if (name !== 'search_court_decisions' && name !== 'get_legislation_section') { + expect(args.limit).toBeLessThanOrEqual(10); + } + } + }); + + it('get_trademark_dossier marks a failed fan-out source without failing the dossier', async () => { + const individual = { ...tmRow, obj_state: 2, registration_number: '67482', + owner_name: 'Дьяконенко Олександр Євгенович', owner_edrpou: null, raw_data: {} }; + const db = makeDossierDb(individual); + const registry = { executeTool: jest.fn(async (name: string) => { + if (name === 'openreyestr_search_debtors') throw new Error('service down'); + return { content: [{ type: 'text', text: '{"results":[]}' }] }; + }) }; + const tool = new IpObjectsTools(db, registry); + const p = parse(await tool.executeTool('get_trademark_dossier', { number: '67482' })); + expect(p.owner_check.probable_matches.debtors).toEqual({ skipped: true, reason: 'service_unavailable' }); + expect(p.owner_check.probable_matches.sanctions).toEqual({ results: [] }); + }); + + it('get_trademark_dossier skips owner_check with a reason when there is no owner at all', async () => { + const db = makeDossierDb({ ...tmRow, obj_state: 2, registration_number: '67482', + owner_name: null, owner_edrpou: null, raw_data: {} }); + const registry = { executeTool: jest.fn(async () => ({ content: [{ type: 'text', text: '{"ok":true}' }] })) }; + const tool = new IpObjectsTools(db, registry); + const p = parse(await tool.executeTool('get_trademark_dossier', { number: '67482' })); + expect(p.owner_check).toEqual({ skipped: true, reason: 'no_owner_identifier' }); }); }); diff --git a/mcp_backend/src/api/tools/ip-objects-tools.ts b/mcp_backend/src/api/tools/ip-objects-tools.ts index 70b865b9..ad0f3480 100644 --- a/mcp_backend/src/api/tools/ip-objects-tools.ts +++ b/mcp_backend/src/api/tools/ip-objects-tools.ts @@ -20,6 +20,8 @@ const OBJ_TYPES = [1, 2, 4, 6]; const OBJ_STATES = [1, 2]; const DEFAULT_LIMIT = 50; const MAX_LIMIT = 100; +// Per-source cap for the individual-owner name fan-out in the dossier. +const OWNER_FANOUT_LIMIT = 10; // Columns returned by the list tools — compact, panel-friendly (no raw_data). const LIST_COLUMNS = `id, obj_type, obj_type_name, obj_state, app_number, app_date, @@ -419,6 +421,58 @@ export class IpObjectsTools extends BaseToolHandler { } } + /** + * Owner check for the dossier. Legal entities (owner_edrpou present) are + * resolved deterministically via the state register. Individuals have no + * РНОКПП in the TM registry, so the only key is the full name — fan out + * best-effort across public registers and mark everything as name-only + * probable matches (однофамільці possible; owner_address is attached for + * manual cross-checking). A downed service never fails the dossier: it is + * reported as { skipped, reason: 'service_unavailable' } instead of null, + * so "nothing to check" and "service down" stay distinguishable. + */ + private async buildOwnerCheck(tm: any): Promise { + if (tm.owner_edrpou) { + const entity = await this.callTool('openreyestr_get_by_edrpou', { edrpou: tm.owner_edrpou }); + return entity ?? { skipped: true, reason: 'service_unavailable' }; + } + const ownerName = String(tm.owner_name ?? '').trim(); + if (!ownerName) return { skipped: true, reason: 'no_owner_identifier' }; + + const sources: Array<[key: string, tool: string, args: any]> = [ + ['debtors', 'openreyestr_search_debtors', { query: ownerName, limit: OWNER_FANOUT_LIMIT }], + ['enforcement_proceedings', 'openreyestr_search_enforcement_proceedings', { query: ownerName, limit: OWNER_FANOUT_LIMIT }], + ['bankruptcy_cases', 'openreyestr_search_bankruptcy_cases', { query: ownerName, limit: OWNER_FANOUT_LIMIT }], + ['sanctions', 'search_registry', { registry: 'sanctions', filters: { name: ownerName }, limit: OWNER_FANOUT_LIMIT }], + // ФОП / участь у юрособах + ['business_entities', 'openreyestr_search_entities', { query: ownerName, limit: OWNER_FANOUT_LIMIT }], + // решта портфеля ІВ цього власника + ['ip_portfolio', 'search_ip_objects', { owner: ownerName, limit: OWNER_FANOUT_LIMIT }], + ['court_hearings', 'search_court_hearing_schedule', { source: 'opendata', participant: ownerName, limit: OWNER_FANOUT_LIMIT }], + ]; + const settled = await Promise.all(sources.map(async ([key, toolName, toolArgs]) => { + const res = await this.callTool(toolName, toolArgs); + return [key, res ?? { skipped: true, reason: 'service_unavailable' }] as const; + })); + + return { + match_basis: 'name_only', + owner_name: ownerName, + owner_address: this.extractOwnerAddress(tm.raw_data), + caveat: 'Збіги знайдено лише за ПІБ — у реєстрі ТМ немає РНОКПП, можливі однофамільці. Звірте owner_address із адресами у знайдених записах вручну.', + probable_matches: Object.fromEntries(settled), + }; + } + + /** Owner address lives only in the SIS dossier (raw_data), not in a column. */ + private extractOwnerAddress(raw: any): any { + const party = raw?.HolderDetails?.Holder?.[0]?.HolderAddressBook + ?? raw?.ApplicantDetails?.Applicant?.[0]?.ApplicantAddressBook; + const address = party?.FormattedNameAddress?.Address; + if (!address) return null; + return address.FreeFormatAddress?.FreeFormatAddressLine ?? address.FreeFormatAddressLine ?? address; + } + /** * One-shot full trademark dossier (matches the reference artifact layout): * disambiguation → registry card + events + legal_status → collisions → @@ -491,10 +545,8 @@ export class IpObjectsTools extends BaseToolHandler { } } - // 4-6. Enrichment via other tools (best-effort, null when a service is down). - const owner_check = tm.owner_edrpou - ? await this.callTool('openreyestr_get_by_edrpou', { edrpou: tm.owner_edrpou }) - : null; + // 4-6. Enrichment via other tools (best-effort, skip markers when a service is down). + const owner_check = await this.buildOwnerCheck(tm); const court_practice = await this.callTool('search_court_decisions', { mode: 'fulltext', justice_kind: 3, limit: 5, query: `${tm.title_ua} свідоцтво недійсне торговельна марка`, @@ -522,7 +574,7 @@ export class IpObjectsTools extends BaseToolHandler { court_practice, temporal, owner_check, - guidance: 'Сформуй повне досьє за розділами: (1) Дизамбігуація та мета; (2) Реєстрові дані та статус; (3) Обсяг охорони (класи МКТП); (4) Правоволодіння; (5) Перевірка на «зіткнення» — таблиця схожих позначень із рівнем ризику, познач найсильніший блокер; (6) Судова практика з ланцюгом інстанцій і статусом позицій; (7) Темпоральний зріз ст. 6 ЗУ №3689-XII (редакція на дату заявки vs чинна); (8) Перевірка правовласника; (9) Підсумок і ризики.', + guidance: 'Сформуй повне досьє за розділами: (1) Дизамбігуація та мета; (2) Реєстрові дані та статус; (3) Обсяг охорони (класи МКТП); (4) Правоволодіння; (5) Перевірка на «зіткнення» — таблиця схожих позначень із рівнем ризику, познач найсильніший блокер; (6) Судова практика з ланцюгом інстанцій і статусом позицій; (7) Темпоральний зріз ст. 6 ЗУ №3689-XII (редакція на дату заявки vs чинна); (8) Перевірка правовласника — якщо owner_check.match_basis="name_only", познач збіги як ймовірні (можливі однофамільці) і запропонуй адресну звірку за owner_address; (9) Підсумок і ризики.', }; return this.wrapResponse(dossier); } catch (error: any) {