Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions mcp_backend/src/api/__tests__/ip-objects-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
});
});
62 changes: 57 additions & 5 deletions mcp_backend/src/api/tools/ip-objects-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<any> {
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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: extractOwnerAddress falls back to the raw address object when neither FreeFormatAddress.FreeFormatAddressLine nor FreeFormatAddressLine exists. The consumer (owner_address in the dossier) expects a string for the AI to use in address cross-checking; returning the entire address object would produce confusing output in that edge case.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At mcp_backend/src/api/tools/ip-objects-tools.ts, line 473:

<comment>`extractOwnerAddress` falls back to the raw `address` object when neither `FreeFormatAddress.FreeFormatAddressLine` nor `FreeFormatAddressLine` exists. The consumer (`owner_address` in the dossier) expects a string for the AI to use in address cross-checking; returning the entire address object would produce confusing output in that edge case.</comment>

<file context>
@@ -419,6 +421,58 @@ export class IpObjectsTools extends BaseToolHandler {
+      ?? raw?.ApplicantDetails?.Applicant?.[0]?.ApplicantAddressBook;
+    const address = party?.FormattedNameAddress?.Address;
+    if (!address) return null;
+    return address.FreeFormatAddress?.FreeFormatAddressLine ?? address.FreeFormatAddressLine ?? address;
+  }
+
</file context>
Suggested change
return address.FreeFormatAddress?.FreeFormatAddressLine ?? address.FreeFormatAddressLine ?? address;
return address.FreeFormatAddress?.FreeFormatAddressLine ?? address.FreeFormatAddressLine ?? null;

}

/**
* One-shot full trademark dossier (matches the reference artifact layout):
* disambiguation → registry card + events + legal_status → collisions →
Expand Down Expand Up @@ -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} свідоцтво недійсне торговельна марка`,
Expand Down Expand Up @@ -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) {
Expand Down