New Indexer fixes (#2546)

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev 2023-01-26 20:53:00 +07:00 committed by GitHub
parent 0cd868ce4a
commit 0235aa5007
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
50 changed files with 1250 additions and 356 deletions

View File

@ -51,7 +51,6 @@ export class TAttachment extends TAttachedDoc implements Attachment {
size!: number size!: number
@Prop(TypeString(), attachment.string.Type) @Prop(TypeString(), attachment.string.Type)
@Index(IndexKind.FullText)
type!: string type!: string
@Prop(TypeTimestamp(), attachment.string.Date) @Prop(TypeTimestamp(), attachment.string.Date)

View File

@ -37,6 +37,7 @@ import {
FullTextData, FullTextData,
FullTextSearchContext, FullTextSearchContext,
IndexKind, IndexKind,
IndexStageState,
Interface, Interface,
Mixin, Mixin,
Obj, Obj,
@ -251,7 +252,13 @@ export class TDocIndexState extends TDoc implements DocIndexState {
removed!: boolean removed!: boolean
// States for diffetent stages // States for diffetent stages
stages!: Record<string, boolean> stages!: Record<string, boolean | string>
}
@Model(core.class.IndexStageState, core.class.Doc, DOMAIN_DOC_INDEX_STATE)
export class TIndexStageState extends TDoc implements IndexStageState {
stageId!: string
attributes!: Record<string, any>
} }
@MMixin(core.mixin.FullTextSearchContext, core.class.Class) @MMixin(core.mixin.FullTextSearchContext, core.class.Class)

View File

@ -46,7 +46,8 @@ import {
TTypeRelatedDocument, TTypeRelatedDocument,
TTypeString, TTypeString,
TTypeTimestamp, TTypeTimestamp,
TVersion TVersion,
TIndexStageState
} from './core' } from './core'
import { TAccount, TSpace } from './security' import { TAccount, TSpace } from './security'
import { TUserStatus } from './transient' import { TUserStatus } from './transient'
@ -99,6 +100,7 @@ export function createModel (builder: Builder): void {
TFulltextData, TFulltextData,
TTypeRelatedDocument, TTypeRelatedDocument,
TDocIndexState, TDocIndexState,
TIndexStageState,
TFullTextSearchContext, TFullTextSearchContext,
TConfiguration, TConfiguration,
TConfigurationElement TConfigurationElement

View File

@ -23,6 +23,7 @@ import {
Mixin, Mixin,
Model, Model,
Prop, Prop,
ReadOnly,
TypeBoolean, TypeBoolean,
TypeDate, TypeDate,
TypeMarkup, TypeMarkup,
@ -34,14 +35,22 @@ import attachment from '@hcengineering/model-attachment'
import calendar from '@hcengineering/model-calendar' import calendar from '@hcengineering/model-calendar'
import chunter from '@hcengineering/model-chunter' import chunter from '@hcengineering/model-chunter'
import contact, { TOrganization, TPerson } from '@hcengineering/model-contact' import contact, { TOrganization, TPerson } from '@hcengineering/model-contact'
import core, { TSpace } from '@hcengineering/model-core' import core, { TAttachedDoc, TSpace } from '@hcengineering/model-core'
import presentation from '@hcengineering/model-presentation' import presentation from '@hcengineering/model-presentation'
import tags from '@hcengineering/model-tags' import tags from '@hcengineering/model-tags'
import task, { actionTemplates, TSpaceWithStates, TTask } from '@hcengineering/model-task' import task, { actionTemplates, DOMAIN_TASK, TSpaceWithStates, TTask } from '@hcengineering/model-task'
import view, { actionTemplates as viewTemplates, createAction } from '@hcengineering/model-view' import view, { actionTemplates as viewTemplates, createAction } from '@hcengineering/model-view'
import workbench, { Application, createNavigateAction } from '@hcengineering/model-workbench' import workbench, { Application, createNavigateAction } from '@hcengineering/model-workbench'
import { IntlString } from '@hcengineering/platform' import { getEmbeddedLabel, IntlString } from '@hcengineering/platform'
import { Applicant, Candidate, Candidates, recruitId, Vacancy, VacancyList } from '@hcengineering/recruit' import {
Applicant,
ApplicantMatch,
Candidate,
Candidates,
recruitId,
Vacancy,
VacancyList
} from '@hcengineering/recruit'
import setting from '@hcengineering/setting' import setting from '@hcengineering/setting'
import { KeyBinding } from '@hcengineering/view' import { KeyBinding } from '@hcengineering/view'
import recruit from './plugin' import recruit from './plugin'
@ -104,6 +113,12 @@ export class TCandidate extends TPerson implements Candidate {
@Prop(Collection(recruit.class.Review, recruit.string.Review), recruit.string.Reviews) @Prop(Collection(recruit.class.Review, recruit.string.Review), recruit.string.Reviews)
reviews?: number reviews?: number
@Prop(
Collection(recruit.class.ApplicantMatch, getEmbeddedLabel('Vacancy match')),
getEmbeddedLabel('Vacancy Matches')
)
vacancyMatch?: number
} }
@Mixin(recruit.mixin.VacancyList, contact.class.Organization) @Mixin(recruit.mixin.VacancyList, contact.class.Organization)
@ -136,8 +151,33 @@ export class TApplicant extends TTask implements Applicant {
declare assignee: Ref<Employee> | null declare assignee: Ref<Employee> | null
} }
@Model(recruit.class.ApplicantMatch, core.class.AttachedDoc, DOMAIN_TASK)
@UX(recruit.string.Application, recruit.icon.Application, recruit.string.ApplicationShort, 'number')
export class TApplicantMatch extends TAttachedDoc implements ApplicantMatch {
// We need to declare, to provide property with label
@Prop(TypeRef(recruit.mixin.Candidate), recruit.string.Talent)
@Index(IndexKind.Indexed)
declare attachedTo: Ref<Candidate>
@Prop(TypeBoolean(), getEmbeddedLabel('Complete'))
@ReadOnly()
complete!: boolean
@Prop(TypeString(), getEmbeddedLabel('Vacancy'))
@ReadOnly()
vacancy!: string
@Prop(TypeString(), getEmbeddedLabel('Summary'))
@ReadOnly()
summary!: string
@Prop(TypeMarkup(), getEmbeddedLabel('Response'))
@ReadOnly()
response!: string
}
export function createModel (builder: Builder): void { export function createModel (builder: Builder): void {
builder.createModel(TVacancy, TCandidates, TCandidate, TApplicant, TReview, TOpinion, TVacancyList) builder.createModel(TVacancy, TCandidates, TCandidate, TApplicant, TReview, TOpinion, TVacancyList, TApplicantMatch)
builder.mixin(recruit.class.Vacancy, core.class.Class, workbench.mixin.SpaceView, { builder.mixin(recruit.class.Vacancy, core.class.Class, workbench.mixin.SpaceView, {
view: { view: {
@ -363,7 +403,7 @@ export function createModel (builder: Builder): void {
} }
] ]
}, },
recruit.viewlet.StatusTableApplicant recruit.viewlet.TableApplicant
) )
builder.createDoc( builder.createDoc(
view.class.Viewlet, view.class.Viewlet,
@ -386,7 +426,19 @@ export function createModel (builder: Builder): void {
], ],
hiddenKeys: ['name'] hiddenKeys: ['name']
}, },
recruit.viewlet.TableApplicant recruit.viewlet.ApplicantTable
)
builder.createDoc(
view.class.Viewlet,
core.space.Model,
{
attachTo: recruit.class.ApplicantMatch,
descriptor: view.viewlet.Table,
config: ['', 'response', 'attachedTo', 'space', 'modifiedOn'],
hiddenKeys: []
},
recruit.viewlet.TableApplicantMatch
) )
const applicantKanbanLookup: Lookup<Applicant> = { const applicantKanbanLookup: Lookup<Applicant> = {
@ -445,6 +497,14 @@ export function createModel (builder: Builder): void {
presenter: recruit.component.ApplicationsPresenter presenter: recruit.component.ApplicationsPresenter
}) })
builder.mixin(recruit.class.ApplicantMatch, core.class.Class, view.mixin.ObjectPresenter, {
presenter: recruit.component.ApplicationMatchPresenter
})
builder.mixin(recruit.class.ApplicantMatch, core.class.Class, view.mixin.CollectionPresenter, {
presenter: recruit.component.ApplicationMatchPresenter
})
builder.mixin(recruit.class.Vacancy, core.class.Class, view.mixin.ObjectPresenter, { builder.mixin(recruit.class.Vacancy, core.class.Class, view.mixin.ObjectPresenter, {
presenter: recruit.component.VacancyPresenter presenter: recruit.component.VacancyPresenter
}) })
@ -643,6 +703,15 @@ export function createModel (builder: Builder): void {
} }
}) })
createAction(builder, {
...viewTemplates.open,
target: recruit.class.ApplicantMatch,
context: {
mode: ['browser', 'context'],
group: 'create'
}
})
function createGotoSpecialAction (builder: Builder, id: string, key: KeyBinding, label: IntlString): void { function createGotoSpecialAction (builder: Builder, id: string, key: KeyBinding, label: IntlString): void {
createNavigateAction(builder, key, label, recruit.app.Recruit as Ref<Application>, { createNavigateAction(builder, key, label, recruit.app.Recruit as Ref<Application>, {
application: recruitId, application: recruitId,
@ -822,6 +891,32 @@ export function createModel (builder: Builder): void {
}, },
recruit.filter.None recruit.filter.None
) )
// Allow to use fuzzy search for mixins
builder.mixin(recruit.class.Vacancy, core.class.Class, core.mixin.FullTextSearchContext, {
fullTextSummary: true
})
createAction(builder, {
label: recruit.string.MatchVacancy,
icon: recruit.icon.Vacancy,
action: view.actionImpl.ShowPopup,
actionProps: {
component: recruit.component.MatchVacancy,
element: 'top',
fillProps: {
_objects: 'objects'
}
},
input: 'any',
category: recruit.category.Recruit,
keyBinding: [],
target: recruit.mixin.Candidate,
context: {
mode: ['context', 'browser'],
group: 'create'
}
})
} }
export { recruitOperation } from './migration' export { recruitOperation } from './migration'

View File

@ -88,7 +88,10 @@ export default mergeIds(recruitId, recruit, {
NewCandidateHeader: '' as AnyComponent, NewCandidateHeader: '' as AnyComponent,
ApplicantFilter: '' as AnyComponent, ApplicantFilter: '' as AnyComponent,
VacancyList: '' as AnyComponent, VacancyList: '' as AnyComponent,
VacancyTemplateEditor: '' as AnyComponent VacancyTemplateEditor: '' as AnyComponent,
ApplicationMatchPresenter: '' as AnyComponent,
MatchVacancy: '' as AnyComponent
}, },
template: { template: {
DefaultVacancy: '' as Ref<KanbanTemplate>, DefaultVacancy: '' as Ref<KanbanTemplate>,
@ -103,8 +106,9 @@ export default mergeIds(recruitId, recruit, {
viewlet: { viewlet: {
TableCandidate: '' as Ref<Viewlet>, TableCandidate: '' as Ref<Viewlet>,
TableVacancy: '' as Ref<Viewlet>, TableVacancy: '' as Ref<Viewlet>,
StatusTableApplicant: '' as Ref<Viewlet>, ApplicantTable: '' as Ref<Viewlet>,
TableApplicant: '' as Ref<Viewlet>, TableApplicant: '' as Ref<Viewlet>,
TableApplicantMatch: '' as Ref<Viewlet>,
CalendarReview: '' as Ref<Viewlet>, CalendarReview: '' as Ref<Viewlet>,
TableReview: '' as Ref<Viewlet> TableReview: '' as Ref<Viewlet>
} }

View File

@ -350,7 +350,7 @@ export interface DocIndexState extends Doc {
attachedToClass?: Ref<Class<Doc>> attachedToClass?: Ref<Class<Doc>>
// States for stages // States for stages
stages: Record<string, boolean> stages: Record<string, boolean | string>
removed: boolean removed: boolean
@ -362,6 +362,14 @@ export interface DocIndexState extends Doc {
shortSummary?: Markup | null shortSummary?: Markup | null
} }
/**
* @public
*/
export interface IndexStageState extends Doc {
stageId: string
attributes: Record<string, any>
}
/** /**
* @public * @public
* *

View File

@ -41,7 +41,8 @@ import type {
Type, Type,
UserStatus, UserStatus,
Configuration, Configuration,
ConfigurationElement ConfigurationElement,
IndexStageState
} from './classes' } from './classes'
import type { import type {
Tx, Tx,
@ -102,6 +103,7 @@ export default plugin(coreId, {
FulltextData: '' as Ref<Class<FullTextData>>, FulltextData: '' as Ref<Class<FullTextData>>,
TypeRelatedDocument: '' as Ref<Class<Type<RelatedDocument>>>, TypeRelatedDocument: '' as Ref<Class<Type<RelatedDocument>>>,
DocIndexState: '' as Ref<Class<DocIndexState>>, DocIndexState: '' as Ref<Class<DocIndexState>>,
IndexStageState: '' as Ref<Class<IndexStageState>>,
Configuration: '' as Ref<Class<Configuration>> Configuration: '' as Ref<Class<Configuration>>
}, },

View File

@ -26,7 +26,7 @@
} }
let search = '' let search = ''
$: summary = (indexDoc?.attributes as any).summary $: summary = indexDoc?.fullSummary ?? undefined
$: attributes = $: attributes =
indexDoc !== undefined indexDoc !== undefined
@ -51,10 +51,17 @@
</script> </script>
<Panel on:changeContent on:close> <Panel on:changeContent on:close>
<EditBox bind:value={search} kind="search-style" /> <EditBox focus bind:value={search} kind="search-style" />
<div class="indexed-background"> <div class="indexed-background">
<div class="indexed-doc text-base max-h-125"> <div class="indexed-doc text-base max-h-125">
{#if summary} {#if summary}
{#if search.length > 0}
Result:
{#each summary.split('\n').filter((line) => line.toLowerCase().includes(search.toLowerCase())) as line}
<span class:highlight={true}>{line}</span>
{/each}
<br />
{/if}
Summary: Summary:
{#each summary.split('\n') as line} {#each summary.split('\n') as line}
{@const hl = search.length > 0 && line.toLowerCase().includes(search.toLowerCase())} {@const hl = search.length > 0 && line.toLowerCase().includes(search.toLowerCase())}
@ -74,6 +81,13 @@
<div class="p-1 flex-row flex-wrap"> <div class="p-1 flex-row flex-wrap">
{#each attr[1] as doc} {#each attr[1] as doc}
<div class="p-1" class:flex-col={doc.length > 1}> <div class="p-1" class:flex-col={doc.length > 1}>
{#if search.length > 0}
Result:
{#each doc.filter((line) => line.toLowerCase().includes(search.toLowerCase())) as line}
<span class:highlight={true}>{line}</span>
{/each}
<br />
{/if}
{#each doc as line} {#each doc as line}
{@const hl = search.length > 0 && line.toLowerCase().includes(search.toLowerCase())} {@const hl = search.length > 0 && line.toLowerCase().includes(search.toLowerCase())}
<span class:text-md={!hl} class:highlight={hl}>{line}</span> <span class:text-md={!hl} class:highlight={hl}>{line}</span>
@ -100,7 +114,7 @@
color: black; color: black;
user-select: text; user-select: text;
.highlight { .highlight {
color: red; color: blue;
} }
} }
</style> </style>

View File

@ -13,18 +13,18 @@
// limitations under the License. // limitations under the License.
--> -->
<script lang="ts"> <script lang="ts">
import { IconFolder, Label } from '@hcengineering/ui' import { IconFolder, IconSize, Label } from '@hcengineering/ui'
import type { Space } from '@hcengineering/core' import type { Space } from '@hcengineering/core'
import presentation from '..' import presentation from '..'
export let value: Space export let value: Space
export let subtitle: string | undefined = undefined export let subtitle: string | undefined = undefined
export let size: 'medium' | 'large' export let size: IconSize
</script> </script>
<div class="flex-row-center"> <div class="flex-row-center">
<div class="flex-center {size} caption-color flex-no-shrink"><IconFolder size={'small'} /></div> <div class="flex-center caption-color flex-no-shrink"><IconFolder {size} /></div>
<div class="flex-col ml-2 min-w-0"> <div class="flex-col ml-2 min-w-0">
{#if subtitle}<div class="content-dark-color text-sm">{subtitle}</div>{/if} {#if subtitle}<div class="content-dark-color text-sm">{subtitle}</div>{/if}
<div class="content-accent-color overflow-label"> <div class="content-accent-color overflow-label">

View File

@ -45,13 +45,14 @@
export let create: ObjectCreate | undefined = undefined export let create: ObjectCreate | undefined = undefined
export let labelDirection: TooltipAlignment | undefined = undefined export let labelDirection: TooltipAlignment | undefined = undefined
export let kind: ButtonKind = 'no-border' export let kind: ButtonKind = 'no-border'
export let size: ButtonSize = 'small' export let size: ButtonSize = 'large'
export let justify: 'left' | 'center' = 'center' export let justify: 'left' | 'center' = 'center'
export let width: string | undefined = undefined export let width: string | undefined = undefined
export let allowDeselect = false export let allowDeselect = false
export let component: AnySvelteComponent | undefined = undefined export let component: AnySvelteComponent | undefined = undefined
export let componentProps: any | undefined = undefined export let componentProps: any | undefined = undefined
export let autoSelect = true export let autoSelect = true
export let readonly = false
let selected: Space | undefined let selected: Space | undefined
@ -75,11 +76,15 @@
$: updateSelected(value) $: updateSelected(value)
const showSpacesPopup = (ev: MouseEvent) => { const showSpacesPopup = (ev: MouseEvent) => {
if (readonly) {
return
}
showPopup( showPopup(
SpacesPopup, SpacesPopup,
{ {
_class, _class,
label, label,
size,
allowDeselect, allowDeselect,
spaceOptions: { ...(spaceOptions ?? {}), sort: { ...(spaceOptions?.sort ?? {}), modifiedOn: -1 } }, spaceOptions: { ...(spaceOptions ?? {}), sort: { ...(spaceOptions?.sort ?? {}), modifiedOn: -1 } },
selected: selected?._id, selected: selected?._id,
@ -109,6 +114,7 @@
<Button <Button
id="space.selector" id="space.selector"
{focus} {focus}
disabled={readonly}
{focusIndex} {focusIndex}
icon={IconFolder} icon={IconFolder}
{size} {size}
@ -118,7 +124,7 @@
showTooltip={{ label, direction: labelDirection }} showTooltip={{ label, direction: labelDirection }}
on:click={showSpacesPopup} on:click={showSpacesPopup}
> >
<span slot="content" class="overflow-label disabled text-sm" class:dark-color={value == null}> <span slot="content" class="overflow-label disabled text" class:dark-color={value == null}>
{#if selected}{selected.name}{:else}<Label {label} />{/if} {#if selected}{selected.name}{:else}<Label {label} />{/if}
</span> </span>
</Button> </Button>

View File

@ -14,7 +14,7 @@
--> -->
<script lang="ts"> <script lang="ts">
import type { Class, Doc, DocumentQuery, FindOptions, Ref, Space } from '@hcengineering/core' import type { Class, Doc, DocumentQuery, FindOptions, Ref, Space } from '@hcengineering/core'
import { AnySvelteComponent } from '@hcengineering/ui' import { AnySvelteComponent, ButtonSize } from '@hcengineering/ui'
import { ObjectCreate } from '../types' import { ObjectCreate } from '../types'
import ObjectPopup from './ObjectPopup.svelte' import ObjectPopup from './ObjectPopup.svelte'
import SpaceInfo from './SpaceInfo.svelte' import SpaceInfo from './SpaceInfo.svelte'
@ -24,6 +24,7 @@
export let spaceQuery: DocumentQuery<Space> | undefined export let spaceQuery: DocumentQuery<Space> | undefined
export let spaceOptions: FindOptions<Space> | undefined = {} export let spaceOptions: FindOptions<Space> | undefined = {}
export let create: ObjectCreate | undefined = undefined export let create: ObjectCreate | undefined = undefined
export let size: ButtonSize = 'large'
export let allowDeselect = false export let allowDeselect = false
export let component: AnySvelteComponent | undefined = undefined export let component: AnySvelteComponent | undefined = undefined
export let componentProps: any | undefined = undefined export let componentProps: any | undefined = undefined
@ -51,9 +52,9 @@
> >
<svelte:fragment slot="item" let:item={space}> <svelte:fragment slot="item" let:item={space}>
{#if component} {#if component}
<svelte:component this={component} {...componentProps} size={'large'} value={space} /> <svelte:component this={component} {...componentProps} {size} value={space} />
{:else} {:else}
<SpaceInfo size={'large'} value={space} /> <SpaceInfo {size} value={space} />
{/if} {/if}
</svelte:fragment> </svelte:fragment>
</ObjectPopup> </ObjectPopup>

View File

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
export let size: 'small' | 'medium' | 'large' import { IconSize } from '../../types'
export let size: IconSize
const fill: string = 'currentColor' const fill: string = 'currentColor'
</script> </script>

View File

@ -46,6 +46,7 @@
<div class="flex-row-center attachment-container"> <div class="flex-row-center attachment-container">
{#if openEmbedded(value.type)} {#if openEmbedded(value.type)}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div <div
class="flex-center icon" class="flex-center icon"
on:click={() => { on:click={() => {
@ -71,6 +72,7 @@
<div class="flex-center icon"> <div class="flex-center icon">
{iconLabel(value.name)} {iconLabel(value.name)}
{#if removable} {#if removable}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div <div
class="remove-btn" class="remove-btn"
on:click={(ev) => { on:click={(ev) => {
@ -86,6 +88,7 @@
{/if} {/if}
<div class="flex-col info"> <div class="flex-col info">
{#if openEmbedded(value.type)} {#if openEmbedded(value.type)}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div <div
class="name" class="name"
on:click={() => { on:click={() => {

View File

@ -26,12 +26,14 @@
</script> </script>
{#if value && value > 0} {#if value && value > 0}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div <div
use:tooltip={{ use:tooltip={{
label: attachment.string.Attachments, label: attachment.string.Attachments,
component: AttachmentPopup, component: AttachmentPopup,
props: { objectId: object._id, attachments: value } props: { objectId: object._id, attachments: value }
}} }}
on:click|preventDefault|stopPropagation={() => {}}
class="sm-tool-icon ml-1 mr-1" class="sm-tool-icon ml-1 mr-1"
> >
<span class="icon"><IconAttachment {size} /></span> <span class="icon"><IconAttachment {size} /></span>

View File

@ -26,12 +26,14 @@
</script> </script>
{#if value && value > 0} {#if value && value > 0}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div <div
use:tooltip={{ use:tooltip={{
label: chunter.string.Comments, label: chunter.string.Comments,
component: CommentPopup, component: CommentPopup,
props: { objectId: object._id } props: { objectId: object._id }
}} }}
on:click|preventDefault|stopPropagation={() => {}}
class="sm-tool-icon ml-1 mr-1" class="sm-tool-icon ml-1 mr-1"
> >
<span class="icon"><IconThread {size} /></span> <span class="icon"><IconThread {size} /></span>

View File

@ -99,7 +99,12 @@
"HasNoActiveApplicant": "No Active", "HasNoActiveApplicant": "No Active",
"NoneApplications": "None", "NoneApplications": "None",
"RelatedIssues": "Related issues", "RelatedIssues": "Related issues",
"VacancyList": "Vacancies" "VacancyList": "Vacancies",
"MatchVacancy": "Match to vacancy",
"VacancyMatching": "Match Talents to vacancy",
"Score": "Score",
"Match": "Match",
"PerformMatch": "Match"
}, },
"status": { "status": {
"TalentRequired": "Please select talent", "TalentRequired": "Please select talent",

View File

@ -101,7 +101,12 @@
"HasNoActiveApplicant": "Не активные", "HasNoActiveApplicant": "Не активные",
"NoneApplications": "Отсутствуют", "NoneApplications": "Отсутствуют",
"RelatedIssues": "Связанные задачи", "RelatedIssues": "Связанные задачи",
"VacancyList": "Вакансии" "VacancyList": "Вакансии",
"MatchVacancy": "Проверить на вакансию",
"VacancyMatching": "Подбор кандидатов на вакансию",
"Score": "Оценка",
"Match": "Совпадение",
"PerformMatch": "Сопоставить"
}, },
"status": { "status": {
"TalentRequired": "Пожалуйста выберите таланта", "TalentRequired": "Пожалуйста выберите таланта",

View File

@ -13,10 +13,23 @@
// limitations under the License. // limitations under the License.
--> -->
<script lang="ts"> <script lang="ts">
import { AttachmentStyledBox } from '@hcengineering/attachment-resources'
import chunter from '@hcengineering/chunter'
import type { Contact, Employee, Person } from '@hcengineering/contact' import type { Contact, Employee, Person } from '@hcengineering/contact'
import contact from '@hcengineering/contact' import contact from '@hcengineering/contact'
import ExpandRightDouble from '@hcengineering/contact-resources/src/components/icons/ExpandRightDouble.svelte' import ExpandRightDouble from '@hcengineering/contact-resources/src/components/icons/ExpandRightDouble.svelte'
import { Account, Class, Client, Doc, FindOptions, generateId, Ref, SortingOrder, Space } from '@hcengineering/core' import {
Account,
Class,
Client,
Doc,
FindOptions,
generateId,
Markup,
Ref,
SortingOrder,
Space
} from '@hcengineering/core'
import { getResource, OK, Resource, Severity, Status } from '@hcengineering/platform' import { getResource, OK, Resource, Severity, Status } from '@hcengineering/platform'
import presentation, { import presentation, {
Card, Card,
@ -32,6 +45,7 @@
Button, Button,
ColorPopup, ColorPopup,
createFocusManager, createFocusManager,
deviceOptionsStore as deviceInfo,
FocusHandler, FocusHandler,
getPlatformColor, getPlatformColor,
Label, Label,
@ -44,13 +58,16 @@
import CandidateCard from './CandidateCard.svelte' import CandidateCard from './CandidateCard.svelte'
import VacancyCard from './VacancyCard.svelte' import VacancyCard from './VacancyCard.svelte'
import VacancyOrgPresenter from './VacancyOrgPresenter.svelte' import VacancyOrgPresenter from './VacancyOrgPresenter.svelte'
import { deviceOptionsStore as deviceInfo } from '@hcengineering/ui'
export let space: Ref<SpaceWithStates> export let space: Ref<SpaceWithStates>
export let candidate: Ref<Candidate> export let candidate: Ref<Candidate>
export let assignee: Ref<Employee> export let assignee: Ref<Employee>
export let comment: Markup = ''
$: _comment = comment
export let preserveCandidate = false export let preserveCandidate = false
export let preserveVacancy = false
let status: Status = OK let status: Status = OK
let createMore: boolean = false let createMore: boolean = false
@ -133,12 +150,22 @@
rank: calcRank(lastOne, undefined), rank: calcRank(lastOne, undefined),
startDate: null, startDate: null,
dueDate: null dueDate: null
} },
doc._id
) )
await descriptionBox.createAttachments()
if (_comment.trim().length > 0) {
await client.addCollection(chunter.class.Comment, _space, doc._id, recruit.class.Applicant, 'comments', {
message: _comment
})
}
if (createMore) { if (createMore) {
// Prepare for next // Prepare for next
_candidate = '' as Ref<Candidate> _candidate = '' as Ref<Candidate>
_comment = ''
doc = { doc = {
state: selectedState?._id as Ref<State>, state: selectedState?._id as Ref<State>,
doneState: null, doneState: null,
@ -256,6 +283,8 @@
let verticalContent: boolean = false let verticalContent: boolean = false
$: verticalContent = $deviceInfo.isMobile && $deviceInfo.isPortrait $: verticalContent = $deviceInfo.isMobile && $deviceInfo.isPortrait
let btn: HTMLButtonElement let btn: HTMLButtonElement
let descriptionBox: AttachmentStyledBox
</script> </script>
<FocusHandler {manager} /> <FocusHandler {manager} />
@ -306,6 +335,7 @@
_class={recruit.class.Vacancy} _class={recruit.class.Vacancy}
spaceQuery={{ archived: false }} spaceQuery={{ archived: false }}
spaceOptions={orgOptions} spaceOptions={orgOptions}
readonly={preserveVacancy}
label={recruit.string.Vacancy} label={recruit.string.Vacancy}
create={{ create={{
component: recruit.component.CreateVacancy, component: recruit.component.CreateVacancy,
@ -324,6 +354,27 @@
</SpaceSelect> </SpaceSelect>
</div> </div>
</div> </div>
{#key doc._id}
<AttachmentStyledBox
bind:this={descriptionBox}
objectId={doc._id}
shouldSaveDraft={false}
_class={recruit.class.Applicant}
space={_space}
alwaysEdit
showButtons={false}
emphasized
bind:content={_comment}
placeholder={recruit.string.Description}
on:changeSize={() => dispatch('changeContent')}
on:attach={(ev) => {
if (ev.detail.action === 'saved') {
doc.attachments = ev.detail.value
}
}}
/>
{/key}
<svelte:fragment slot="pool"> <svelte:fragment slot="pool">
{#key doc} {#key doc}
<EmployeeBox <EmployeeBox

View File

@ -0,0 +1,258 @@
<script lang="ts">
import contact from '@hcengineering/contact'
import core, { Doc, DocIndexState, FindOptions, Ref } from '@hcengineering/core'
import presentation, {
Card,
createQuery,
getClient,
IndexedDocumentPreview,
MessageViewer,
SpaceSelect
} from '@hcengineering/presentation'
import { Applicant, ApplicantMatch, Candidate, Vacancy } from '@hcengineering/recruit'
import { Button, IconActivity, IconAdd, Label, resizeObserver, showPopup, tooltip } from '@hcengineering/ui'
import Scroller from '@hcengineering/ui/src/components/Scroller.svelte'
import { MarkupPreviewPopup, ObjectPresenter } from '@hcengineering/view-resources'
import { cosinesim } from '@hcengineering/view-resources/src/utils'
import { createEventDispatcher } from 'svelte'
import recruit from '../plugin'
import CreateApplication from './CreateApplication.svelte'
import VacancyCard from './VacancyCard.svelte'
import VacancyOrgPresenter from './VacancyOrgPresenter.svelte'
export let objects: Candidate[] | Candidate
$: _objects = Array.isArray(objects) ? objects : [objects]
const orgOptions: FindOptions<Vacancy> = {
lookup: {
company: contact.class.Organization
}
}
let _space: Ref<Vacancy> | undefined
let vacancy: Vacancy | undefined
const vacancyQuery = createQuery()
$: vacancyQuery.query(recruit.class.Vacancy, { _id: _space }, (res) => {
;[vacancy] = res
})
const indexDataQuery = createQuery()
let state: Map<Ref<Doc>, DocIndexState> = new Map()
$: indexDataQuery.query(
core.class.DocIndexState,
{
_id: {
$in: [_space as unknown as Ref<DocIndexState>, ..._objects.map((it) => it._id as unknown as Ref<DocIndexState>)]
}
},
(res) => {
state = new Map(res.map((it) => [it._id, it] ?? []))
}
)
$: vacancyState = state.get(_space as unknown as Ref<DocIndexState>)
const matchQuery = createQuery()
let matches: Map<Ref<Doc>, ApplicantMatch> = new Map()
$: matchQuery.query(
recruit.class.ApplicantMatch,
{
attachedTo: { $in: [..._objects.map((it) => it._id)] },
space: _space
},
(res) => {
matches = new Map(res.map((it) => [it.attachedTo, it] ?? []))
}
)
const applicationQuery = createQuery()
let applications: Map<Ref<Doc>, Applicant> = new Map()
$: applicationQuery.query(
recruit.class.Applicant,
{
attachedTo: { $in: [..._objects.map((it) => it._id)] },
space: _space
},
(res) => {
applications = new Map(res.map((it) => [it.attachedTo, it] ?? []))
}
)
function getEmbedding (doc: DocIndexState): number[] | undefined {
for (const [k, v] of Object.entries(doc.attributes)) {
if (k.startsWith('openai_embedding_') && doc.attributes[k + '_use'] === true) {
return v
}
}
}
$: vacancyEmbedding = vacancyState && getEmbedding(vacancyState)
const dispatch = createEventDispatcher()
const client = getClient()
const matching = new Set<string>()
async function requestMatch (doc: Candidate, docState: DocIndexState): Promise<void> {
try {
matching.add(doc._id)
if (_space === undefined) {
return
}
const oldMatch = matches.get(doc._id)
if (oldMatch) {
await client.remove(oldMatch)
}
await client.addCollection(recruit.class.ApplicantMatch, _space, doc._id, doc._class, 'vacancyMatch', {
complete: false,
vacancy: vacancyState?.fullSummary ?? '',
summary: docState.fullSummary ?? '',
response: ''
})
} finally {
matching.delete(doc._id)
}
}
async function createApplication (doc: Candidate, match?: ApplicantMatch): Promise<void> {
showPopup(
CreateApplication,
{
space: _space,
candidate: doc._id,
preserveCandidate: true,
preserveVacancy: true,
comment: match?.response ?? ''
},
'top'
)
}
async function showSummary (doc: Candidate): Promise<void> {
showPopup(IndexedDocumentPreview, { objectId: doc._id }, 'top')
}
</script>
<Card
label={recruit.string.VacancyMatching}
okLabel={presentation.string.Ok}
on:close
okAction={() => {}}
canSave={true}
>
<Scroller horizontal>
<div
class="flex-row flex-nowrap"
use:resizeObserver={() => {
dispatch('changeContent')
}}
>
<div class="flex-row-center antiEmphasized">
<div class="p-1 flex-grow">
<SpaceSelect
size={'large'}
_class={recruit.class.Vacancy}
spaceQuery={{ archived: false }}
spaceOptions={orgOptions}
label={recruit.string.Vacancy}
create={{
component: recruit.component.CreateVacancy,
label: recruit.string.CreateVacancy
}}
bind:value={_space}
on:change={(evt) => {
_space = evt.detail
}}
component={VacancyOrgPresenter}
componentProps={{ inline: true }}
>
<svelte:fragment slot="content">
<VacancyCard {vacancy} disabled={true} />
</svelte:fragment>
</SpaceSelect>
</div>
<div class="p-1">
{#if vacancy}
<Scroller>
<div class="flex-col max-h-60">
{#if vacancy.description}
{vacancy.description}
{/if}
{#if vacancyState?.fullSummary}
<MessageViewer message={vacancyState?.fullSummary.split('\n').join('<br/>')} />
{/if}
</div>
</Scroller>
{/if}
</div>
</div>
<Scroller>
<table class="antiTable mt-2">
<thead class="scroller-thead">
<tr class="scroller-thead__tr">
<td><Label label={recruit.string.Talent} /></td>
<td><Label label={recruit.string.Score} /></td>
<td><Label label={recruit.string.Match} /></td>
<td>#</td>
</tr>
</thead>
<tbody>
{#each _objects as doc}
{@const docState = state.get(doc._id)}
{@const docEmbedding = docState && getEmbedding(docState)}
{@const match = matches.get(doc._id)}
{@const appl = applications.get(doc._id)}
<tr class="antiTable-body__row">
<td>
<div class="flex-row-center">
<ObjectPresenter objectId={doc._id} _class={doc._class} value={doc} />
{#if appl}
<div class="ml-2 flex-row-center">
<ObjectPresenter objectId={appl._id} _class={appl._class} value={appl} />
</div>
{/if}
</div>
</td>
<td>
{#if docEmbedding && vacancyEmbedding}
{Math.round(cosinesim(docEmbedding, vacancyEmbedding) * 100)}
{/if}
</td>
<td>
{#if match?.complete}
<div
class="lines-limit-4"
use:tooltip={{ component: MarkupPreviewPopup, props: { value: match.response } }}
>
<MessageViewer message={match.response} />
</div>
{/if}
</td>
<td class="flex-row-center gap-2">
{#if docState}
<Button
label={recruit.string.PerformMatch}
loading={matching.has(doc._id) || !(match?.complete ?? true)}
on:click={() => requestMatch(doc, docState)}
/>
<Button
icon={IconActivity}
showTooltip={{ label: presentation.string.DocumentPreview }}
on:click={() => showSummary(doc)}
/>
<Button
icon={IconAdd}
disabled={appl !== undefined}
showTooltip={{ label: recruit.string.CreateVacancy }}
on:click={() => createApplication(doc, match)}
/>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</Scroller>
</div>
</Scroller>
</Card>

View File

@ -79,6 +79,7 @@
</div> </div>
{/if} {/if}
{#if vacancy} {#if vacancy}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div <div
class="name lines-limit-2" class="name lines-limit-2"
class:over-underline={!disabled} class:over-underline={!disabled}
@ -95,22 +96,24 @@
} }
}} }}
> >
{#if inline} <div class="text-md">
<div class="flex-row-center"> {#if inline}
<VacancyIcon size={'small'} /> <div class="flex-row-center">
<span class="ml-1"> <VacancyIcon size={'small'} />
{vacancy.name} <span class="ml-1">
</span> {vacancy.name}
</div> </span>
{:else} </div>
{vacancy.name} {:else}
{/if} {vacancy.name}
{/if}
</div>
</div> </div>
{#if company} {#if company}
<span class="label">{company.name}</span> <span class="label">{company.name}</span>
{/if} {/if}
{#if !inline || vacancy.description} {#if !inline || vacancy.description}
<div class="description lines-limit-2">{vacancy.description ?? ''}</div> <div class="description lines-limit-2 text-md">{vacancy.description ?? ''}</div>
{/if} {/if}
<div class="footer flex flex-reverse flex-grow"> <div class="footer flex flex-reverse flex-grow">

View File

@ -62,6 +62,7 @@ import recruit from './plugin'
import { objectIdProvider, objectLinkProvider, getApplicationTitle } from './utils' import { objectIdProvider, objectLinkProvider, getApplicationTitle } from './utils'
import VacancyList from './components/VacancyList.svelte' import VacancyList from './components/VacancyList.svelte'
import VacancyTemplateEditor from './components/VacancyTemplateEditor.svelte' import VacancyTemplateEditor from './components/VacancyTemplateEditor.svelte'
import MatchVacancy from './components/MatchVacancy.svelte'
async function createOpinion (object: Doc): Promise<void> { async function createOpinion (object: Doc): Promise<void> {
showPopup(CreateOpinion, { space: object.space, review: object._id }) showPopup(CreateOpinion, { space: object.space, review: object._id })
@ -300,7 +301,9 @@ export default async (): Promise<Resources> => ({
ApplicantFilter, ApplicantFilter,
VacancyList, VacancyList,
VacancyTemplateEditor VacancyTemplateEditor,
MatchVacancy
}, },
completion: { completion: {
ApplicationQuery: async ( ApplicationQuery: async (

View File

@ -111,7 +111,13 @@ export default mergeIds(recruitId, recruit, {
HasActiveApplicant: '' as IntlString, HasActiveApplicant: '' as IntlString,
HasNoActiveApplicant: '' as IntlString, HasNoActiveApplicant: '' as IntlString,
NoneApplications: '' as IntlString, NoneApplications: '' as IntlString,
RelatedIssues: '' as IntlString RelatedIssues: '' as IntlString,
MatchVacancy: '' as IntlString,
VacancyMatching: '' as IntlString,
Score: '' as IntlString,
Match: '' as IntlString,
PerformMatch: '' as IntlString
}, },
space: { space: {
CandidatesPublic: '' as Ref<Space> CandidatesPublic: '' as Ref<Space>

View File

@ -89,6 +89,18 @@ export interface Applicant extends Task {
comments?: number comments?: number
} }
/**
* @public
*/
export interface ApplicantMatch extends AttachedDoc {
attachedTo: Ref<Candidate>
complete: boolean
vacancy: string
summary: string
response: string
}
/** /**
* @public * @public
*/ */
@ -129,6 +141,7 @@ const recruit = plugin(recruitId, {
}, },
class: { class: {
Applicant: '' as Ref<Class<Applicant>>, Applicant: '' as Ref<Class<Applicant>>,
ApplicantMatch: '' as Ref<Class<ApplicantMatch>>,
Candidates: '' as Ref<Class<Candidates>>, Candidates: '' as Ref<Class<Candidates>>,
Vacancy: '' as Ref<Class<Vacancy>>, Vacancy: '' as Ref<Class<Vacancy>>,
Review: '' as Ref<Class<Review>>, Review: '' as Ref<Class<Review>>,

View File

@ -61,6 +61,7 @@
"FilteredViews": "Filtered views", "FilteredViews": "Filtered views",
"NewFilteredView": "New filtered view", "NewFilteredView": "New filtered view",
"FilteredViewName": "Filtered view name", "FilteredViewName": "Filtered view name",
"Than": "Than" "Than": "Than",
"ShowPreviewOnClick": "Please click to show document index preview..."
} }
} }

View File

@ -58,6 +58,7 @@
"FilteredViews": "Фильтрованные отображения", "FilteredViews": "Фильтрованные отображения",
"NewFilteredView": "Новое фильтрованное отображение", "NewFilteredView": "Новое фильтрованное отображение",
"FilteredViewName": "Имя фильтрованного отображения", "FilteredViewName": "Имя фильтрованного отображения",
"Than": "Затем" "Than": "Затем",
"ShowPreviewOnClick": "Пожалуйста нажмите чтобы увидеть предпросмотр..."
} }
} }

View File

@ -0,0 +1,36 @@
<!--
// Copyright © 2020, 2021 Anticrm Platform Contributors.
// Copyright © 2021 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { MessageViewer } from '@hcengineering/presentation'
import { deviceOptionsStore as deviceInfo, resizeObserver } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
export let value: string
const dispatch = createEventDispatcher()
</script>
<div
class="antiCard {$deviceInfo.isMobile ? 'mobile' : 'dialog'}"
use:resizeObserver={() => {
dispatch('changeContent')
}}
on:close={() => dispatch('close', null)}
>
<div class="flex-grow mt-4">
<MessageViewer message={value} />
</div>
</div>

View File

@ -46,6 +46,8 @@
export let tableId: string | undefined = undefined export let tableId: string | undefined = undefined
export let readonly = false export let readonly = false
export let prefferedSorting: string = 'modifiedOn'
// If defined, will show a number of dummy items before real data will appear. // If defined, will show a number of dummy items before real data will appear.
export let loadingProps: LoadingProps | undefined = undefined export let loadingProps: LoadingProps | undefined = undefined
@ -57,10 +59,16 @@
$: lookup = options?.lookup ?? buildConfigLookup(hierarchy, _class, config) $: lookup = options?.lookup ?? buildConfigLookup(hierarchy, _class, config)
let sortKey = 'modifiedOn' let _sortKey = prefferedSorting
$: if (!userSorting) {
_sortKey = prefferedSorting
}
let sortOrder = SortingOrder.Descending let sortOrder = SortingOrder.Descending
let loading = 0 let loading = 0
let userSorting = false
let objects: Doc[] = [] let objects: Doc[] = []
let objectsRecieved = false let objectsRecieved = false
const refs: HTMLElement[] = [] const refs: HTMLElement[] = []
@ -71,7 +79,7 @@
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
$: sortingFunction = (config.find((it) => typeof it !== 'string' && it.sortingKey === sortKey) as BuildModelKey) $: sortingFunction = (config.find((it) => typeof it !== 'string' && it.sortingKey === _sortKey) as BuildModelKey)
?.sortingFunction ?.sortingFunction
async function update ( async function update (
@ -107,7 +115,7 @@
objects = [] objects = []
} }
} }
$: update(_class, query, sortKey, sortOrder, lookup, options) $: update(_class, query, _sortKey, sortOrder, lookup, options)
const showMenu = async (ev: MouseEvent, object: Doc, row: number): Promise<void> => { const showMenu = async (ev: MouseEvent, object: Doc, row: number): Promise<void> => {
selection = row selection = row
@ -121,12 +129,13 @@
}) })
} }
function changeSorting (key: string): void { function changeSorting (key: string | string[]): void {
if (key === '') { if (key === '') {
return return
} }
if (key !== sortKey) { userSorting = true
sortKey = key if (key !== _sortKey) {
_sortKey = Array.isArray(key) ? key[0] : key
sortOrder = SortingOrder.Ascending sortOrder = SortingOrder.Ascending
} else { } else {
sortOrder = sortOrder === SortingOrder.Ascending ? SortingOrder.Descending : SortingOrder.Ascending sortOrder = sortOrder === SortingOrder.Ascending ? SortingOrder.Descending : SortingOrder.Ascending
@ -211,14 +220,14 @@
{#each model as attribute} {#each model as attribute}
<th <th
class:sortable={attribute.sortingKey} class:sortable={attribute.sortingKey}
class:sorted={attribute.sortingKey === sortKey} class:sorted={attribute.sortingKey === _sortKey}
on:click={() => changeSorting(attribute.sortingKey)} on:click={() => changeSorting(attribute.sortingKey)}
> >
<div class="antiTable-cells"> <div class="antiTable-cells">
{#if attribute.label} {#if attribute.label}
<Label label={attribute.label} /> <Label label={attribute.label} />
{/if} {/if}
{#if attribute.sortingKey === sortKey} {#if attribute.sortingKey === _sortKey}
<div class="icon"> <div class="icon">
{#if sortOrder === SortingOrder.Ascending} {#if sortOrder === SortingOrder.Ascending}
<IconUp size={'small'} /> <IconUp size={'small'} />

View File

@ -14,12 +14,14 @@
--> -->
<script lang="ts"> <script lang="ts">
import type { Class, Doc, DocumentQuery, FindOptions, Ref } from '@hcengineering/core' import type { Class, Doc, DocumentQuery, FindOptions, Ref } from '@hcengineering/core'
import { getEmbeddedLabel } from '@hcengineering/platform'
import { Scroller, tableSP } from '@hcengineering/ui' import { Scroller, tableSP } from '@hcengineering/ui'
import { BuildModelKey } from '@hcengineering/view' import { BuildModelKey } from '@hcengineering/view'
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { ActionContext } from '..' import { ActionContext } from '..'
import { focusStore, ListSelectionProvider, SelectDirection, selectionStore } from '../selection' import { focusStore, ListSelectionProvider, SelectDirection, selectionStore } from '../selection'
import { LoadingProps } from '../utils' import { LoadingProps } from '../utils'
import SourcePresenter from './inference/SourcePresenter.svelte'
import Table from './Table.svelte' import Table from './Table.svelte'
export let _class: Ref<Class<Doc>> export let _class: Ref<Class<Doc>>
@ -44,6 +46,32 @@
onMount(() => { onMount(() => {
;(document.activeElement as HTMLElement)?.blur() ;(document.activeElement as HTMLElement)?.blur()
}) })
// Search config
let _config = config
let prefferedSorting: string = 'modifiedOn'
function updateConfig (config: (BuildModelKey | string)[], search?: string): void {
const useSearch = search !== '' && search != null
_config = [
...(useSearch
? [
{
key: '',
presenter: SourcePresenter,
label: getEmbeddedLabel('#'),
sortingKey: '#score',
props: { search }
}
]
: []),
...config
]
prefferedSorting = !useSearch ? 'modifiedOn' : '#score'
}
$: updateConfig(config, query.$search)
</script> </script>
<svelte:window /> <svelte:window />
@ -57,7 +85,7 @@
<Table <Table
bind:this={table} bind:this={table}
{_class} {_class}
{config} config={_config}
{options} {options}
{query} {query}
{showNotification} {showNotification}
@ -66,6 +94,7 @@
highlightRows={true} highlightRows={true}
enableChecking enableChecking
checked={$selectionStore ?? []} checked={$selectionStore ?? []}
{prefferedSorting}
selection={listProvider.current($focusStore)} selection={listProvider.current($focusStore)}
on:row-focus={(evt) => { on:row-focus={(evt) => {
listProvider.updateFocus(evt.detail) listProvider.updateFocus(evt.detail)

View File

@ -15,7 +15,8 @@
<script lang="ts"> <script lang="ts">
import { Doc, WithLookup } from '@hcengineering/core' import { Doc, WithLookup } from '@hcengineering/core'
import { IndexedDocumentPreview } from '@hcengineering/presentation' import { IndexedDocumentPreview } from '@hcengineering/presentation'
import { showPopup } from '@hcengineering/ui' import { showPopup, tooltip } from '@hcengineering/ui'
import plugin from '../../plugin'
export let value: WithLookup<Doc> export let value: WithLookup<Doc>
export let search: string export let search: string
@ -23,7 +24,14 @@
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<span <span
use:tooltip={{ label: plugin.string.ShowPreviewOnClick }}
on:click={() => { on:click={() => {
showPopup(IndexedDocumentPreview, { objectId: value._id, search }) showPopup(IndexedDocumentPreview, { objectId: value._id, search })
}}>{value.$source?.$score}</span }}
> >
{#if value.$source?.$score}
{Math.round(value.$source?.$score * 100) / 100}
{:else}
*
{/if}
</span>

View File

@ -31,14 +31,23 @@ import EditBoxPopup from './components/EditBoxPopup.svelte'
import EditDoc from './components/EditDoc.svelte' import EditDoc from './components/EditDoc.svelte'
import EnumEditor from './components/EnumEditor.svelte' import EnumEditor from './components/EnumEditor.svelte'
import FilterBar from './components/filter/FilterBar.svelte' import FilterBar from './components/filter/FilterBar.svelte'
import FilterTypePopup from './components/filter/FilterTypePopup.svelte'
import ObjectFilter from './components/filter/ObjectFilter.svelte' import ObjectFilter from './components/filter/ObjectFilter.svelte'
import TimestampFilter from './components/filter/TimestampFilter.svelte' import TimestampFilter from './components/filter/TimestampFilter.svelte'
import ValueFilter from './components/filter/ValueFilter.svelte' import ValueFilter from './components/filter/ValueFilter.svelte'
import FilterTypePopup from './components/filter/FilterTypePopup.svelte' import HTMLEditor from './components/HTMLEditor.svelte'
import HTMLPresenter from './components/HTMLPresenter.svelte' import HTMLPresenter from './components/HTMLPresenter.svelte'
import HyperlinkPresenter from './components/HyperlinkPresenter.svelte'
import IntlStringPresenter from './components/IntlStringPresenter.svelte' import IntlStringPresenter from './components/IntlStringPresenter.svelte'
import GithubPresenter from './components/linkPresenters/GithubPresenter.svelte' import GithubPresenter from './components/linkPresenters/GithubPresenter.svelte'
import YoutubePresenter from './components/linkPresenters/YoutubePresenter.svelte' import YoutubePresenter from './components/linkPresenters/YoutubePresenter.svelte'
import GrowPresenter from './components/list/GrowPresenter.svelte'
import ListView from './components/list/ListView.svelte'
import SortableList from './components/list/SortableList.svelte'
import SortableListItem from './components/list/SortableListItem.svelte'
import MarkupEditor from './components/MarkupEditor.svelte'
import MarkupEditorPopup from './components/MarkupEditorPopup.svelte'
import MarkupPresenter from './components/MarkupPresenter.svelte'
import Menu from './components/Menu.svelte' import Menu from './components/Menu.svelte'
import NumberEditor from './components/NumberEditor.svelte' import NumberEditor from './components/NumberEditor.svelte'
import NumberPresenter from './components/NumberPresenter.svelte' import NumberPresenter from './components/NumberPresenter.svelte'
@ -47,31 +56,22 @@ import RolePresenter from './components/RolePresenter.svelte'
import SpacePresenter from './components/SpacePresenter.svelte' import SpacePresenter from './components/SpacePresenter.svelte'
import StringEditor from './components/StringEditor.svelte' import StringEditor from './components/StringEditor.svelte'
import StringPresenter from './components/StringPresenter.svelte' import StringPresenter from './components/StringPresenter.svelte'
import HyperlinkPresenter from './components/HyperlinkPresenter.svelte'
import Table from './components/Table.svelte' import Table from './components/Table.svelte'
import TableBrowser from './components/TableBrowser.svelte' import TableBrowser from './components/TableBrowser.svelte'
import TimestampPresenter from './components/TimestampPresenter.svelte' import TimestampPresenter from './components/TimestampPresenter.svelte'
import UpDownNavigator from './components/UpDownNavigator.svelte' import UpDownNavigator from './components/UpDownNavigator.svelte'
import ViewletSettingButton from './components/ViewletSettingButton.svelte'
import ValueSelector from './components/ValueSelector.svelte' import ValueSelector from './components/ValueSelector.svelte'
import HTMLEditor from './components/HTMLEditor.svelte' import ViewletSettingButton from './components/ViewletSettingButton.svelte'
import MarkupPresenter from './components/MarkupPresenter.svelte'
import MarkupEditor from './components/MarkupEditor.svelte'
import MarkupEditorPopup from './components/MarkupEditorPopup.svelte'
import SortableList from './components/list/SortableList.svelte'
import SortableListItem from './components/list/SortableListItem.svelte'
import ListView from './components/list/ListView.svelte'
import GrowPresenter from './components/list/GrowPresenter.svelte'
import { import {
afterResult, afterResult,
beforeResult, beforeResult,
nestedDontMatchResult,
nestedMatchResult,
objectInResult, objectInResult,
objectNinResult, objectNinResult,
valueInResult, valueInResult,
valueNinResult, valueNinResult
nestedMatchResult,
nestedDontMatchResult
} from './filter' } from './filter'
import { IndexedDocumentPreview } from '@hcengineering/presentation' import { IndexedDocumentPreview } from '@hcengineering/presentation'
@ -84,30 +84,31 @@ export { getActions, invokeAction } from './actions'
export { default as ActionContext } from './components/ActionContext.svelte' export { default as ActionContext } from './components/ActionContext.svelte'
export { default as ActionHandler } from './components/ActionHandler.svelte' export { default as ActionHandler } from './components/ActionHandler.svelte'
export { default as FilterButton } from './components/filter/FilterButton.svelte' export { default as FilterButton } from './components/filter/FilterButton.svelte'
export { default as LinkPresenter } from './components/LinkPresenter.svelte'
export { default as ContextMenu } from './components/Menu.svelte'
export { default as TableBrowser } from './components/TableBrowser.svelte'
export { default as FixedColumn } from './components/FixedColumn.svelte' export { default as FixedColumn } from './components/FixedColumn.svelte'
export { default as ValueSelector } from './components/ValueSelector.svelte' export { default as SourcePresenter } from './components/inference/SourcePresenter.svelte'
export { default as LinkPresenter } from './components/LinkPresenter.svelte'
export { default as List } from './components/list/List.svelte'
export { default as ContextMenu } from './components/Menu.svelte'
export { default as ObjectBox } from './components/ObjectBox.svelte' export { default as ObjectBox } from './components/ObjectBox.svelte'
export { default as ObjectPresenter } from './components/ObjectPresenter.svelte' export { default as ObjectPresenter } from './components/ObjectPresenter.svelte'
export { default as TableBrowser } from './components/TableBrowser.svelte'
export { default as List } from './components/list/List.svelte' export { default as ValueSelector } from './components/ValueSelector.svelte'
export { default as MarkupPreviewPopup } from './components/MarkupPreviewPopup.svelte'
export * from './context' export * from './context'
export * from './filter' export * from './filter'
export * from './selection' export * from './selection'
export * from './viewOptions'
export { export {
buildModel, buildModel,
getActiveViewletId,
getCollectionCounter, getCollectionCounter,
getFiltredKeys,
getObjectPresenter, getObjectPresenter,
getObjectPreview, getObjectPreview,
isCollectionAttr,
LoadingProps, LoadingProps,
setActiveViewletId, setActiveViewletId
getActiveViewletId,
getFiltredKeys,
isCollectionAttr
} from './utils' } from './utils'
export * from './viewOptions'
export { export {
HTMLPresenter, HTMLPresenter,
Table, Table,

View File

@ -0,0 +1,33 @@
import core, { Class, Client, Doc, DocIndexState, Ref } from '@hcengineering/core'
import { writable } from 'svelte/store'
export interface InferenceFocus {
objectId: Ref<Doc>
objectClass: Ref<Class<Doc>>
state: DocIndexState
}
/**
* @public
*/
export const inferenceFocusStore = writable<InferenceFocus | undefined>(undefined)
/**
* @public
*/
export async function updateFocus (client: Client, doc?: { _id: Ref<Doc>, _class: Ref<Class<Doc>> }): Promise<void> {
if (doc === undefined) {
inferenceFocusStore.set(undefined)
return
}
const state = await client.findOne(core.class.DocIndexState, {
_id: doc._id as Ref<DocIndexState>,
objectClass: doc._class
})
if (state !== undefined) {
inferenceFocusStore.update(() => {
return { objectId: doc._id, objectClass: doc._class, state }
})
}
}

View File

@ -61,6 +61,7 @@ export default mergeIds(viewId, view, {
Grouping: '' as IntlString, Grouping: '' as IntlString,
Ordering: '' as IntlString, Ordering: '' as IntlString,
Manual: '' as IntlString, Manual: '' as IntlString,
Than: '' as IntlString Than: '' as IntlString,
ShowPreviewOnClick: '' as IntlString
} }
}) })

View File

@ -548,3 +548,22 @@ export function getKeyLabel<T extends Doc> (
return attribute.label return attribute.label
} }
} }
/**
* @public
* Implemenation of cosice similarity
*/
export function cosinesim (A: number[], B: number[]): number {
let dotproduct = 0
let mA = 0
let mB = 0
for (let i = 0; i < A.length; i++) {
dotproduct += A[i] * B[i]
mA += A[i] * A[i]
mB += B[i] * B[i]
}
mA = Math.sqrt(mA)
mB = Math.sqrt(mB)
const similarity = dotproduct / (mA * mB) // here you needed extra brackets
return similarity
}

View File

@ -14,7 +14,7 @@
--> -->
<script lang="ts"> <script lang="ts">
import { Class, Doc, DocumentQuery, Ref, Space, WithLookup } from '@hcengineering/core' import { Class, Doc, DocumentQuery, Ref, Space, WithLookup } from '@hcengineering/core'
import { Asset, getEmbeddedLabel, IntlString } from '@hcengineering/platform' import { Asset, IntlString } from '@hcengineering/platform'
import { createQuery, getClient } from '@hcengineering/presentation' import { createQuery, getClient } from '@hcengineering/presentation'
import { import {
AnyComponent, AnyComponent,
@ -36,7 +36,6 @@
setActiveViewletId, setActiveViewletId,
ViewletSettingButton ViewletSettingButton
} from '@hcengineering/view-resources' } from '@hcengineering/view-resources'
import SourcePresenter from './search/SourcePresenter.svelte'
export let _class: Ref<Class<Doc>> export let _class: Ref<Class<Doc>>
export let space: Ref<Space> | undefined = undefined export let space: Ref<Space> | undefined = undefined
@ -139,20 +138,7 @@
_class, _class,
space, space,
options: viewlet.options, options: viewlet.options,
config: [ config: preference?.config ?? viewlet.config,
...(search !== ''
? [
{
key: '',
presenter: SourcePresenter,
label: getEmbeddedLabel('#'),
sortingKey: '#score',
props: { search }
}
]
: []),
...(preference?.config ?? viewlet.config)
],
viewlet, viewlet,
viewOptions, viewOptions,
createItemDialog: createComponent, createItemDialog: createComponent,

View File

@ -98,45 +98,60 @@ import { trackerId } from '@hcengineering/tracker'
import { viewId } from '@hcengineering/view' import { viewId } from '@hcengineering/view'
import { workbenchId } from '@hcengineering/workbench' import { workbenchId } from '@hcengineering/workbench'
addStringsLoader(loginId, async (lang: string) => await import(`@hcengineering/login-assets/lang/${lang}.json`)) import loginEng from '@hcengineering/login-assets/lang/en.json'
addStringsLoader(taskId, async (lang: string) => await import(`@hcengineering/task-assets/lang/${lang}.json`))
addStringsLoader(viewId, async (lang: string) => await import(`@hcengineering/view-assets/lang/${lang}.json`))
addStringsLoader(chunterId, async (lang: string) => await import(`@hcengineering/chunter-assets/lang/${lang}.json`))
addStringsLoader(
attachmentId,
async (lang: string) => await import(`@hcengineering/attachment-assets/lang/${lang}.json`)
)
addStringsLoader(contactId, async (lang: string) => await import(`@hcengineering/contact-assets/lang/${lang}.json`))
addStringsLoader(recruitId, async (lang: string) => await import(`@hcengineering/recruit-assets/lang/${lang}.json`))
addStringsLoader(activityId, async (lang: string) => await import(`@hcengineering/activity-assets/lang/${lang}.json`))
addStringsLoader(
automationId,
async (lang: string) => await import(`@hcengineering/automation-assets/lang/${lang}.json`)
)
addStringsLoader(settingId, async (lang: string) => await import(`@hcengineering/setting-assets/lang/${lang}.json`))
addStringsLoader(telegramId, async (lang: string) => await import(`@hcengineering/telegram-assets/lang/${lang}.json`))
addStringsLoader(leadId, async (lang: string) => await import(`@hcengineering/lead-assets/lang/${lang}.json`))
addStringsLoader(gmailId, async (lang: string) => await import(`@hcengineering/gmail-assets/lang/${lang}.json`))
addStringsLoader(workbenchId, async (lang: string) => await import(`@hcengineering/workbench-assets/lang/${lang}.json`))
addStringsLoader(inventoryId, async (lang: string) => await import(`@hcengineering/inventory-assets/lang/${lang}.json`))
addStringsLoader(templatesId, async (lang: string) => await import(`@hcengineering/templates-assets/lang/${lang}.json`))
addStringsLoader(
notificationId,
async (lang: string) => await import(`@hcengineering/notification-assets/lang/${lang}.json`)
)
addStringsLoader(tagsId, async (lang: string) => await import(`@hcengineering/tags-assets/lang/${lang}.json`))
addStringsLoader(calendarId, async (lang: string) => await import(`@hcengineering/calendar-assets/lang/${lang}.json`))
addStringsLoader(trackerId, async (lang: string) => await import(`@hcengineering/tracker-assets/lang/${lang}.json`))
addStringsLoader(boardId, async (lang: string) => await import(`@hcengineering/board-assets/lang/${lang}.json`))
addStringsLoader(
preferenceId,
async (lang: string) => await import(`@hcengineering/preference-assets/lang/${lang}.json`)
)
addStringsLoader(hrId, async (lang: string) => await import(`@hcengineering/hr-assets/lang/${lang}.json`))
addStringsLoader(documentId, async (lang: string) => await import(`@hcengineering/document-assets/lang/${lang}.json`))
addStringsLoader(bitrixId, async (lang: string) => await import(`@hcengineering/bitrix-assets/lang/${lang}.json`))
addStringsLoader(requestId, async (lang: string) => await import(`@hcengineering/request-assets/lang/${lang}.json`))
import taskEn from '@hcengineering/task-assets/lang/en.json'
import viewEn from '@hcengineering/view-assets/lang/en.json'
import chunterEn from '@hcengineering/chunter-assets/lang/en.json'
import attachmentEn from '@hcengineering/attachment-assets/lang/en.json'
import contactEn from '@hcengineering/contact-assets/lang/en.json'
import recruitEn from '@hcengineering/recruit-assets/lang/en.json'
import activityEn from '@hcengineering/activity-assets/lang/en.json'
import automationEn from '@hcengineering/automation-assets/lang/en.json'
import settingEn from '@hcengineering/setting-assets/lang/en.json'
import telegramEn from '@hcengineering/telegram-assets/lang/en.json'
import leadEn from '@hcengineering/lead-assets/lang/en.json'
import gmailEn from '@hcengineering/gmail-assets/lang/en.json'
import workbenchEn from '@hcengineering/workbench-assets/lang/en.json'
import inventoryEn from '@hcengineering/inventory-assets/lang/en.json'
import templatesEn from '@hcengineering/templates-assets/lang/en.json'
import notificationEn from '@hcengineering/notification-assets/lang/en.json'
import tagsEn from '@hcengineering/tags-assets/lang/en.json'
import calendarEn from '@hcengineering/calendar-assets/lang/en.json'
import trackerEn from '@hcengineering/tracker-assets/lang/en.json'
import boardEn from '@hcengineering/board-assets/lang/en.json'
import preferenceEn from '@hcengineering/preference-assets/lang/en.json'
import hrEn from '@hcengineering/hr-assets/lang/en.json'
import documentEn from '@hcengineering/document-assets/lang/en.json'
import bitrixEn from '@hcengineering/bitrix-assets/lang/en.json'
import requestEn from '@hcengineering/request-assets/lang/en.json'
addStringsLoader(loginId, async (lang: string) => loginEng)
addStringsLoader(taskId, async (lang: string) => taskEn)
addStringsLoader(viewId, async (lang: string) => viewEn)
addStringsLoader(chunterId, async (lang: string) => chunterEn)
addStringsLoader(attachmentId, async (lang: string) => attachmentEn)
addStringsLoader(contactId, async (lang: string) => contactEn)
addStringsLoader(recruitId, async (lang: string) => recruitEn)
addStringsLoader(activityId, async (lang: string) => activityEn)
addStringsLoader(automationId, async (lang: string) => automationEn)
addStringsLoader(settingId, async (lang: string) => settingEn)
addStringsLoader(telegramId, async (lang: string) => telegramEn)
addStringsLoader(leadId, async (lang: string) => leadEn)
addStringsLoader(gmailId, async (lang: string) => gmailEn)
addStringsLoader(workbenchId, async (lang: string) => workbenchEn)
addStringsLoader(inventoryId, async (lang: string) => inventoryEn)
addStringsLoader(templatesId, async (lang: string) => templatesEn)
addStringsLoader(notificationId, async (lang: string) => notificationEn)
addStringsLoader(tagsId, async (lang: string) => tagsEn)
addStringsLoader(calendarId, async (lang: string) => calendarEn)
addStringsLoader(trackerId, async (lang: string) => trackerEn)
addStringsLoader(boardId, async (lang: string) => boardEn)
addStringsLoader(preferenceId, async (lang: string) => preferenceEn)
addStringsLoader(hrId, async (lang: string) => hrEn)
addStringsLoader(documentId, async (lang: string) => documentEn)
addStringsLoader(bitrixId, async (lang: string) => bitrixEn)
addStringsLoader(requestId, async (lang: string) => requestEn)
/** /**
* @public * @public
*/ */

View File

@ -33,6 +33,7 @@
"@hcengineering/server-core": "^0.6.1", "@hcengineering/server-core": "^0.6.1",
"@hcengineering/server": "^0.6.4", "@hcengineering/server": "^0.6.4",
"@hcengineering/chunter": "^0.6.2", "@hcengineering/chunter": "^0.6.2",
"@hcengineering/recruit": "^0.6.4",
"got": "^11.8.3", "got": "^11.8.3",
"fast-equals": "^2.0.3", "fast-equals": "^2.0.3",
"html-to-text": "^9.0.3" "html-to-text": "^9.0.3"

View File

@ -20,6 +20,7 @@ import core, {
DocumentQuery, DocumentQuery,
DocumentUpdate, DocumentUpdate,
docUpdKey, docUpdKey,
IndexStageState,
MeasureContext, MeasureContext,
Ref, Ref,
Storage, Storage,
@ -36,6 +37,7 @@ import {
FullTextPipelineStage, FullTextPipelineStage,
IndexedDoc, IndexedDoc,
isIndexingRequired, isIndexingRequired,
loadIndexStageStage,
RateLimitter RateLimitter
} from '@hcengineering/server-core' } from '@hcengineering/server-core'
@ -47,7 +49,7 @@ import openaiPlugin, { openAIRatelimitter } from './plugin'
/** /**
* @public * @public
*/ */
export const openAIstage = 'emb-v3a' export const openAIstage = 'emb-v5'
/** /**
* @public * @public
@ -76,34 +78,37 @@ export class OpenAIEmbeddingsStage implements FullTextPipelineStage {
field = 'openai_embedding' field = 'openai_embedding'
field_enabled = '_use' field_enabled = '_use'
summary_field = 'summary'
enabled = false enabled = false
clearExcept?: string[] = undefined clearExcept?: string[] = undefined
updateFields: DocUpdateHandler[] = [] updateFields: DocUpdateHandler[] = []
model = process.env.OPENAI_MODEL ?? 'text-embedding-ada-002' copyToState = true
model = 'text-embedding-ada-002'
tokenLimit = 8191 tokenLimit = 8191
endpoint = process.env.OPENAI_HOST ?? 'https://api.openai.com/v1/embeddings' endpoint = 'https://api.openai.com/v1/embeddings'
token = '' token = ''
rate = 5 rate = 5
stageValue: boolean | string = true
limitter = new RateLimitter(() => ({ rate: this.rate })) limitter = new RateLimitter(() => ({ rate: this.rate }))
indexState?: IndexStageState
async update (doc: DocIndexState, update: DocumentUpdate<DocIndexState>): Promise<void> {} async update (doc: DocIndexState, update: DocumentUpdate<DocIndexState>): Promise<void> {}
constructor (readonly adapter: FullTextAdapter, readonly metrics: MeasureContext, readonly workspaceId: WorkspaceId) {} constructor (readonly adapter: FullTextAdapter, readonly metrics: MeasureContext, readonly workspaceId: WorkspaceId) {}
updateSummary (summary: FullSummaryStage): void { updateSummary (summary: FullSummaryStage): void {
summary.fieldFilter.push((attr, value) => { summary.fieldFilter.push((attr, value) => {
if ( const tMarkup = attr.type._class === core.class.TypeMarkup
attr.type._class === core.class.TypeMarkup && const lowerCase = value.toLocaleLowerCase()
(value.toLocaleLowerCase().startsWith('gpt:') || value.toLocaleLowerCase().startsWith('gpt Answer:')) if (tMarkup && (lowerCase.includes('gpt:') || lowerCase.includes('gpt Answer:'))) {
) {
return false return false
} }
return true return true
@ -150,6 +155,15 @@ export class OpenAIEmbeddingsStage implements FullTextPipelineStage {
console.error(err) console.error(err)
this.enabled = false this.enabled = false
} }
;[this.stageValue, this.indexState] = await loadIndexStageStage(storage, this.indexState, this.stageId, 'config', {
enabled: this.enabled,
endpoint: this.endpoint,
field: this.field,
mode: this.model,
copyToState: this.copyToState,
stripNewLines: true
})
} }
async getEmbedding (text: string): Promise<OpenAIEmbeddingResponse> { async getEmbedding (text: string): Promise<OpenAIEmbeddingResponse> {
@ -232,17 +246,18 @@ export class OpenAIEmbeddingsStage implements FullTextPipelineStage {
} }
} }
if (query.$search === undefined) return { docs: [], pass: true } if (query.$search === undefined) return { docs: [], pass: true }
const embeddingData = await this.getEmbedding(query.$search) const queryString = query.$search.replace('\n ', ' ')
const embeddingData = await this.getEmbedding(queryString)
const embedding = embeddingData.data[0].embedding const embedding = embeddingData.data[0].embedding
console.log('search embedding', embedding) console.log('search embedding', embedding)
const docs = await this.adapter.searchEmbedding(_classes, query, embedding, { const docs = await this.adapter.searchEmbedding(_classes, query, embedding, {
size, size,
from, from,
minScore: 0, minScore: -100,
embeddingBoost: 100, embeddingBoost: 100,
field: this.field, field: this.field,
field_enable: this.field_enabled, field_enable: this.field_enabled,
fulltextBoost: 1 fulltextBoost: 10
}) })
return { return {
docs, docs,
@ -251,6 +266,9 @@ export class OpenAIEmbeddingsStage implements FullTextPipelineStage {
} }
async collect (toIndex: DocIndexState[], pipeline: FullTextPipeline): Promise<void> { async collect (toIndex: DocIndexState[], pipeline: FullTextPipeline): Promise<void> {
if (!this.enabled) {
return
}
for (const doc of toIndex) { for (const doc of toIndex) {
if (pipeline.cancelling) { if (pipeline.cancelling) {
return return
@ -274,13 +292,7 @@ export class OpenAIEmbeddingsStage implements FullTextPipelineStage {
// No need to index this class, mark embeddings as empty ones. // No need to index this class, mark embeddings as empty ones.
if (!needIndex) { if (!needIndex) {
await pipeline.update(doc._id, true, {}) await pipeline.update(doc._id, this.stageValue, {})
return
}
if (this.token === '') {
// No token, just do nothing.
await pipeline.update(doc._id, true, {})
return return
} }
@ -288,10 +300,11 @@ export class OpenAIEmbeddingsStage implements FullTextPipelineStage {
if (this.unauthorized) { if (this.unauthorized) {
return return
} }
const embeddingText = (doc.attributes[this.summary_field] as string) ?? '' const embeddingText = doc.fullSummary ?? ''
if (embeddingText.length > this.treshold) { if (embeddingText.length > this.treshold) {
const embeddText = embeddingText // replace newlines, which can negatively affect performance. Based on OpenAI examples.
const embeddText = embeddingText.replace('\n ', ' ')
console.log('calculate embeddings:', doc.objectClass, doc._id) console.log('calculate embeddings:', doc.objectClass, doc._id)
@ -322,7 +335,11 @@ export class OpenAIEmbeddingsStage implements FullTextPipelineStage {
[this.field]: embedding, [this.field]: embedding,
[this.field_enabled]: true [this.field_enabled]: true
}) })
;(update as any)[docUpdKey(this.field)] = embedding.length if (this.copyToState) {
;(update as any)[docUpdKey(this.field)] = embedding
} else {
;(update as any)[docUpdKey(this.field)] = embedding.length
}
;(update as any)[docUpdKey(this.field_enabled)] = true ;(update as any)[docUpdKey(this.field_enabled)] = true
} }
} catch (err: any) { } catch (err: any) {
@ -337,18 +354,15 @@ export class OpenAIEmbeddingsStage implements FullTextPipelineStage {
} }
// Print error only first time, and update it in doc index // Print error only first time, and update it in doc index
console.error(err) console.error(err)
return
} }
// We need to collect all fields and prepare embedding document. await pipeline.update(doc._id, this.stageValue, update)
await pipeline.update(doc._id, true, update)
} }
async remove (docs: DocIndexState[], pipeline: FullTextPipeline): Promise<void> { async remove (docs: DocIndexState[], pipeline: FullTextPipeline): Promise<void> {
// will be handled by field processor // will be handled by field processor
for (const doc of docs) { for (const doc of docs) {
await pipeline.update(doc._id, true, {}) await pipeline.update(doc._id, this.stageValue, {})
} }
} }
} }

View File

@ -24,15 +24,79 @@ import core, {
TxCollectionCUD, TxCollectionCUD,
TxCreateDoc, TxCreateDoc,
TxCUD, TxCUD,
TxProcessor, TxProcessor
TxUpdateDoc
} from '@hcengineering/core' } from '@hcengineering/core'
import type { TriggerControl } from '@hcengineering/server-core' import type { TriggerControl } from '@hcengineering/server-core'
import got from 'got' import got from 'got'
import { convert } from 'html-to-text' import { convert } from 'html-to-text'
import { encode } from './encoder/encoder' import { chunks, encode } from './encoder/encoder'
import openai, { openAIRatelimitter } from './plugin' import openai, { OpenAIConfiguration, openAIRatelimitter } from './plugin'
import recruit, { ApplicantMatch } from '@hcengineering/recruit'
const model = 'text-davinci-003'
const defaultOptions = {
max_tokens: 4000,
temperature: 0.9,
top_p: 1,
n: 1,
stop: null as string | null
}
async function performCompletion (
prompt: string,
options: typeof defaultOptions,
config: OpenAIConfiguration
): Promise<any> {
const ep = config.endpoint + '/completions'
const chunkedPrompt = chunks(prompt, options.max_tokens - 250)[0]
const tokens = encode(chunkedPrompt).length
let response: any
while (true) {
try {
response = await openAIRatelimitter.exec(
async () =>
await got
.post(ep, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${config.token}`
},
json: {
model,
prompt: chunkedPrompt,
max_tokens: options.max_tokens - tokens,
temperature: options.temperature,
top_p: options.top_p,
n: options.n,
stream: false,
logprobs: null,
stop: options.stop
},
timeout: 180000
})
.json()
)
break
} catch (e: any) {
const msg = (e.message as string) ?? ''
if (
msg.includes('Response code 429 (Too Many Requests)') ||
msg.includes('Response code 503 (Service Unavailable)')
) {
await new Promise((resolve) => {
setTimeout(resolve, 1000)
})
continue
}
console.error(e)
return []
}
}
return response
}
/** /**
* @public * @public
*/ */
@ -43,156 +107,200 @@ export async function OnGPTRequest (tx: Tx, tc: TriggerControl): Promise<Tx[]> {
const cud: TxCUD<Doc> = actualTx as TxCUD<Doc> const cud: TxCUD<Doc> = actualTx as TxCUD<Doc>
// //
if (tc.hierarchy.isDerived(cud.objectClass, chunter.class.Comment)) { if (tc.hierarchy.isDerived(cud.objectClass, chunter.class.Comment)) {
let msg = '' return await handleComment(tx, tc)
// }
if (actualTx._class === core.class.TxCreateDoc) { if (tc.hierarchy.isDerived(cud.objectClass, recruit.class.ApplicantMatch)) {
msg = (cud as TxCreateDoc<Comment>).attributes.message return await handleApplicantMatch(tx, tc)
} else if (actualTx._class === core.class.TxUpdateDoc) {
msg = (cud as TxUpdateDoc<Comment>).operations.message ?? ''
}
const text = convert(msg, {
preserveNewlines: true,
selectors: [{ selector: 'img', format: 'skip' }]
})
if (text.toLocaleLowerCase().startsWith('gpt:')) {
const [config] = await tc.findAll(openai.class.OpenAIConfiguration, {})
if (config?.enabled ?? false) {
// Elanbed, we could complete.
const split = text.split('\n')
let prompt = split.slice(1).join('\n').trim()
// Do prompt modifications.
const matches: string[] = []
for (const m of prompt.matchAll(/\${(\w+)}/gm)) {
for (const mm of m.values()) {
if (!mm.startsWith('${')) {
matches.push(mm)
}
}
}
const parentTx = tx as TxCollectionCUD<Doc, Comment>
const [indexedData] = await tc.findAll(core.class.DocIndexState, {
_id: parentTx.objectId as Ref<DocIndexState>
})
const [parentDoc] = await tc.findAll(parentTx.objectClass, { _id: parentTx.objectId as Ref<DocIndexState> })
if (matches.length > 0) {
if (indexedData !== undefined) {
// Fill values in prompt.
for (const m of matches) {
const val = indexedData.attributes[m] ?? (parentDoc as any)[m]
if (val !== undefined) {
prompt = prompt.replace(`\${${m}}`, val)
}
}
}
}
const options = {
max_tokens: 4000,
temperature: 0.9,
top_p: 1,
n: 1,
stop: null as string | null
}
const configLine = split[0].slice(4).split(',')
for (const cfg of configLine) {
const vals = cfg.trim().split('=')
if (vals.length === 2) {
switch (vals[0].trim()) {
case 'max_tokens':
options.max_tokens = parseInt(vals[1])
break
case 'temperature':
options.temperature = parseFloat(vals[1])
break
case 'top_p':
options.top_p = parseInt(vals[1])
break
case 'n':
options.n = parseInt(vals[1])
break
case 'stop':
options.stop = vals[1]
break
}
}
}
const ep = config.endpoint + '/completions'
const tokens = encode(prompt).length
let response: any
try {
response = await openAIRatelimitter.exec(
async () =>
await got
.post(ep, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${config.token}`
},
json: {
model: 'text-davinci-003',
prompt,
max_tokens: options.max_tokens - tokens,
temperature: options.temperature,
top_p: options.top_p,
n: options.n,
stream: false,
logprobs: null,
stop: options.stop
},
timeout: 60000
})
.json()
)
} catch (e: any) {
console.error(e)
}
console.log('response is good')
const result: Tx[] = []
for (const choices of response.choices) {
const msgTx = tc.txFactory.createTxCreateDoc(chunter.class.Comment, tx.objectSpace, {
message: 'gpt Answer:\n<br/>' + (choices.text as string).replace('\n', '\n<br/>'),
attachedTo: parentTx.objectId,
attachedToClass: parentTx.objectClass,
collection: parentTx.collection
})
// msgTx.modifiedBy = openai.account.GPT
const col = tc.txFactory.createTxCollectionCUD(
parentTx.objectClass,
parentTx.objectId,
parentTx.objectSpace,
parentTx.collection,
msgTx
)
// col.modifiedBy = openai.account.GPT
result.push(col)
}
// Store response transactions
await tc.txFx(async (st) => {
for (const t of result) {
await st.tx(t)
}
})
return result
}
}
} }
} }
return [] return []
} }
async function handleComment (tx: Tx, tc: TriggerControl): Promise<Tx[]> {
const actualTx = TxProcessor.extractTx(tx)
const cud: TxCUD<Doc> = actualTx as TxCUD<Doc>
let msg = ''
//
if (actualTx._class === core.class.TxCreateDoc) {
msg = (cud as TxCreateDoc<Comment>).attributes.message
}
const text = convert(msg, {
preserveNewlines: true,
selectors: [{ selector: 'img', format: 'skip' }]
})
if (text.toLocaleLowerCase().startsWith('gpt:')) {
const [config] = await tc.findAll(openai.class.OpenAIConfiguration, {})
if (config?.enabled ?? false) {
// Elanbed, we could complete.
const split = text.split('\n')
let prompt = split.slice(1).join('\n').trim()
// Do prompt modifications.
const matches: string[] = []
for (const m of prompt.matchAll(/\${(\w+)}/gm)) {
for (const mm of m.values()) {
if (!mm.startsWith('${')) {
matches.push(mm)
}
}
}
const parentTx = tx as TxCollectionCUD<Doc, Comment>
const [indexedData] = await tc.findAll(core.class.DocIndexState, {
_id: parentTx.objectId as Ref<DocIndexState>
})
const [parentDoc] = await tc.findAll(parentTx.objectClass, { _id: parentTx.objectId as Ref<DocIndexState> })
const values: Record<string, any> = {
...indexedData.attributes,
...parentDoc,
summary: indexedData.fullSummary,
shortSummary: indexedData.shortSummary
}
if (matches.length > 0) {
if (indexedData !== undefined) {
// Fill values in prompt.
for (const m of matches) {
const val = values[m]
if (val !== undefined) {
prompt = prompt.replace(`\${${m}}`, val)
}
}
}
}
const options = parseOptions(split)
const response = await performCompletion(prompt, options, config)
const result: Tx[] = []
let finalMsg = msg + '</br>'
for (const choices of response.choices) {
const val = (choices.text as string).trim().split('\n').join('\n<br/>')
finalMsg += `<p>Answer:\n<br/>${val}</p>`
}
const msgTx = tc.txFactory.createTxUpdateDoc<Comment>(
cud.objectClass,
cud.objectSpace,
cud.objectId as Ref<Comment>,
{
message: finalMsg
}
)
// msgTx.modifiedBy = openai.account.GPT
const col = tc.txFactory.createTxCollectionCUD(
parentTx.objectClass,
parentTx.objectId,
parentTx.objectSpace,
parentTx.collection,
msgTx
)
// col.modifiedBy = openai.account.GPT
result.push(col)
// Store response transactions
await tc.txFx(async (st) => {
for (const t of result) {
await st.tx(t)
}
})
return result
}
}
return []
}
async function handleApplicantMatch (tx: Tx, tc: TriggerControl): Promise<Tx[]> {
const [config] = await tc.findAll(openai.class.OpenAIConfiguration, {})
if (!(config?.enabled ?? false)) {
return []
}
const actualTx = TxProcessor.extractTx(tx)
const parentTx = tx as TxCollectionCUD<Doc, ApplicantMatch>
if (actualTx._class !== core.class.TxCreateDoc) {
return []
}
const cud: TxCreateDoc<ApplicantMatch> = actualTx as TxCreateDoc<ApplicantMatch>
const options: typeof defaultOptions = {
...defaultOptions,
temperature: 0.1
}
const maxAnswerTokens = 500
const maxVacancyTokens = options.max_tokens - maxAnswerTokens / 2
const maxCandidateTokens = maxVacancyTokens
let candidateText = cud.attributes.summary
candidateText = convert(candidateText, {
preserveNewlines: true,
selectors: [{ selector: 'img', format: 'skip' }]
})
candidateText = chunks(candidateText, maxCandidateTokens)[0]
let vacancyText = cud.attributes.vacancy
vacancyText = convert(vacancyText, {
preserveNewlines: true,
selectors: [{ selector: 'img', format: 'skip' }]
})
vacancyText = chunks(vacancyText, maxVacancyTokens)[0]
// Enabled, we could complete.
const text = `'Considering following vacancy:\n ${vacancyText}\n write if following candidate good for vacancy and why:\n ${candidateText}\n`
const response = await performCompletion(text, options, config)
const result: Tx[] = []
let finalMsg = ''
for (const choices of response.choices) {
let val = (choices.text as string).trim()
// Add new line before Reason:
val = val.split('\n\n').join('\n')
val = val.replace('Reason:', '\nReason:')
val = val.replace('Candidate is', '\nCandidate is')
val = val.replace(/Match score: (\d+\/\d+|\d+%) /gi, (val) => val + '\n')
val = val.split('\n').join('\n<br/>')
finalMsg += `<p>${val}</p>`
}
const msgTx = tc.txFactory.createTxUpdateDoc<ApplicantMatch>(cud.objectClass, cud.objectSpace, cud.objectId, {
response: finalMsg,
complete: true
})
// msgTx.modifiedBy = openai.account.GPT
const col = tc.txFactory.createTxCollectionCUD(
parentTx.objectClass,
parentTx.objectId,
parentTx.objectSpace,
parentTx.collection,
msgTx
)
// col.modifiedBy = openai.account.GPT
result.push(col)
// Store response transactions
await tc.txFx(async (st) => {
for (const t of result) {
await st.tx(t)
}
})
return result
}
/** /**
* @public * @public
*/ */
@ -201,3 +309,30 @@ export const openAIPluginImpl = async () => ({
OnGPTRequest OnGPTRequest
} }
}) })
function parseOptions (split: string[]): typeof defaultOptions {
const options = defaultOptions
const configLine = split[0].slice(4).split(',')
for (const cfg of configLine) {
const vals = cfg.trim().split('=')
if (vals.length === 2) {
switch (vals[0].trim()) {
case 'max_tokens':
options.max_tokens = parseInt(vals[1])
break
case 'temperature':
options.temperature = parseFloat(vals[1])
break
case 'top_p':
options.top_p = parseInt(vals[1])
break
case 'n':
options.n = parseInt(vals[1])
break
case 'stop':
options.stop = vals[1]
break
}
}
}
return options
}

View File

@ -48,6 +48,8 @@ export class ContentRetrievalStage implements FullTextPipelineStage {
textLimit = 100 * 1024 textLimit = 100 * 1024
stageValue: boolean | string = true
constructor ( constructor (
readonly storageAdapter: MinioService | undefined, readonly storageAdapter: MinioService | undefined,
readonly workspace: WorkspaceId, readonly workspace: WorkspaceId,

View File

@ -50,6 +50,8 @@ export class IndexedFieldStage implements FullTextPipelineStage {
enabled = true enabled = true
stageValue: boolean | string = true
constructor (private readonly dbStorage: ServerStorage, readonly metrics: MeasureContext) {} constructor (private readonly dbStorage: ServerStorage, readonly metrics: MeasureContext) {}
async initialize (storage: Storage, pipeline: FullTextPipeline): Promise<void> { async initialize (storage: Storage, pipeline: FullTextPipeline): Promise<void> {

View File

@ -54,6 +54,8 @@ export class FullTextPushStage implements FullTextPipelineStage {
field_enabled = '_use' field_enabled = '_use'
stageValue: boolean | string = true
constructor ( constructor (
readonly fulltextAdapter: FullTextAdapter, readonly fulltextAdapter: FullTextAdapter,
readonly workspace: WorkspaceId, readonly workspace: WorkspaceId,

View File

@ -23,6 +23,7 @@ import core, {
DOMAIN_DOC_INDEX_STATE, DOMAIN_DOC_INDEX_STATE,
Hierarchy, Hierarchy,
MeasureContext, MeasureContext,
ModelDb,
Ref, Ref,
ServerStorage, ServerStorage,
setObjectValue, setObjectValue,
@ -77,7 +78,8 @@ export class FullTextIndexPipeline implements FullTextPipeline {
private readonly stages: FullTextPipelineStage[], private readonly stages: FullTextPipelineStage[],
readonly hierarchy: Hierarchy, readonly hierarchy: Hierarchy,
readonly workspace: WorkspaceId, readonly workspace: WorkspaceId,
readonly metrics: MeasureContext readonly metrics: MeasureContext,
readonly model: ModelDb
) { ) {
this.readyStages = stages.map((it) => it.stageId) this.readyStages = stages.map((it) => it.stageId)
this.readyStages.sort() this.readyStages.sort()
@ -101,6 +103,7 @@ export class FullTextIndexPipeline implements FullTextPipeline {
): Promise<{ docs: IndexedDoc[], pass: boolean }> { ): Promise<{ docs: IndexedDoc[], pass: boolean }> {
const result: IndexedDoc[] = [] const result: IndexedDoc[] = []
for (const st of this.stages) { for (const st of this.stages) {
await st.initialize(this.storage, this)
const docs = await st.search(_classes, search, size, from) const docs = await st.search(_classes, search, size, from)
result.push(...docs.docs) result.push(...docs.docs)
if (!docs.pass) { if (!docs.pass) {
@ -154,7 +157,7 @@ export class FullTextIndexPipeline implements FullTextPipeline {
// Update are commulative // Update are commulative
async update ( async update (
docId: Ref<DocIndexState>, docId: Ref<DocIndexState>,
mark: boolean, mark: boolean | string,
update: DocumentUpdate<DocIndexState>, update: DocumentUpdate<DocIndexState>,
flush?: boolean flush?: boolean
): Promise<void> { ): Promise<void> {
@ -162,7 +165,7 @@ export class FullTextIndexPipeline implements FullTextPipeline {
if (udoc !== undefined) { if (udoc !== undefined) {
await this.stageUpdate(udoc, update) await this.stageUpdate(udoc, update)
udoc = this.updateDoc(udoc, update, mark) udoc = this.updateDoc(udoc, update, mark !== false)
this.toIndex.set(docId, udoc) this.toIndex.set(docId, udoc)
} }
@ -170,7 +173,7 @@ export class FullTextIndexPipeline implements FullTextPipeline {
udoc = this.toIndexParents.get(docId) udoc = this.toIndexParents.get(docId)
if (udoc !== undefined) { if (udoc !== undefined) {
await this.stageUpdate(udoc, update) await this.stageUpdate(udoc, update)
udoc = this.updateDoc(udoc, update, mark) udoc = this.updateDoc(udoc, update, mark !== false)
this.toIndexParents.set(docId, udoc) this.toIndexParents.set(docId, udoc)
} }
} }
@ -294,7 +297,7 @@ export class FullTextIndexPipeline implements FullTextPipeline {
const result = await this.storage.findAll( const result = await this.storage.findAll(
core.class.DocIndexState, core.class.DocIndexState,
{ {
[`stages.${st.stageId}`]: { $nin: [true] }, [`stages.${st.stageId}`]: { $nin: [st.stageValue] },
_id: { $nin: toSkip }, _id: { $nin: toSkip },
removed: false removed: false
}, },
@ -346,7 +349,7 @@ export class FullTextIndexPipeline implements FullTextPipeline {
// Check items with not updated state. // Check items with not updated state.
for (const d of toIndex) { for (const d of toIndex) {
if (!d.stages?.[st.stageId]) { if (d.stages?.[st.stageId] === false) {
this.skipped.set(d._id, (this.skipped.get(d._id) ?? 0) + 1) this.skipped.set(d._id, (this.skipped.get(d._id) ?? 0) + 1)
} else { } else {
this.skipped.delete(d._id) this.skipped.delete(d._id)
@ -420,7 +423,7 @@ export class FullTextIndexPipeline implements FullTextPipeline {
} }
for (const st of statistics) { for (const st of statistics) {
for (const [s, v] of Object.entries(st.stages ?? {})) { for (const [s, v] of Object.entries(st.stages ?? {})) {
if (v && allStageIds.has(s)) { if (v !== false && allStageIds.has(s)) {
this.stats[s] = (this.stats[s] ?? 0) + 1 this.stats[s] = (this.stats[s] ?? 0) + 1
} }
} }

View File

@ -20,23 +20,24 @@ import core, {
DocIndexState, DocIndexState,
DocumentQuery, DocumentQuery,
DocumentUpdate, DocumentUpdate,
docUpdKey,
extractDocKey, extractDocKey,
FullTextSearchContext, FullTextSearchContext,
Hierarchy, Hierarchy,
IndexStageState,
isFullTextAttribute, isFullTextAttribute,
Ref, Ref,
Storage Storage
} from '@hcengineering/core' } from '@hcengineering/core'
import { translate } from '@hcengineering/platform' import { translate } from '@hcengineering/platform'
import { convert } from 'html-to-text'
import { IndexedDoc } from '../types' import { IndexedDoc } from '../types'
import { contentStageId, DocUpdateHandler, fieldStateId, FullTextPipeline, FullTextPipelineStage } from './types' import { contentStageId, DocUpdateHandler, fieldStateId, FullTextPipeline, FullTextPipelineStage } from './types'
import { convert } from 'html-to-text' import { loadIndexStageStage } from './utils'
/** /**
* @public * @public
*/ */
export const summaryStageId = 'sum-v2' export const summaryStageId = 'sum-v3a'
/** /**
* @public * @public
@ -54,11 +55,22 @@ export class FullSummaryStage implements FullTextPipelineStage {
// If specified, index only fields with content speciffied. // If specified, index only fields with content speciffied.
matchExtra: string[] = [] // 'content', 'base64'] // '#en' matchExtra: string[] = [] // 'content', 'base64'] // '#en'
summaryField = 'summary'
fieldFilter: ((attr: AnyAttribute, value: string) => boolean)[] = [] fieldFilter: ((attr: AnyAttribute, value: string) => boolean)[] = []
async initialize (storage: Storage, pipeline: FullTextPipeline): Promise<void> {} stageValue: boolean | string = true
indexState?: IndexStageState
async initialize (storage: Storage, pipeline: FullTextPipeline): Promise<void> {
const indexable = (
await pipeline.model.findAll(core.class.Class, { [core.mixin.FullTextSearchContext + '.fullTextSummary']: true })
).map((it) => it._id)
indexable.sort()
;[this.stageValue, this.indexState] = await loadIndexStageStage(storage, this.indexState, this.stageId, 'config', {
classes: indexable,
matchExtra: this.matchExtra
})
}
async search ( async search (
_classes: Ref<Class<Doc>>[], _classes: Ref<Class<Doc>>[],
@ -79,7 +91,7 @@ export class FullSummaryStage implements FullTextPipelineStage {
// No need to index this class, mark embeddings as empty ones. // No need to index this class, mark embeddings as empty ones.
if (!needIndex) { if (!needIndex) {
await pipeline.update(doc._id, true, {}) await pipeline.update(doc._id, this.stageValue, {})
continue continue
} }
@ -89,16 +101,16 @@ export class FullSummaryStage implements FullTextPipelineStage {
matchExtra: this.matchExtra, matchExtra: this.matchExtra,
fieldFilter: this.fieldFilter fieldFilter: this.fieldFilter
}) })
;(update as any)[docUpdKey(this.summaryField)] = embeddingText update.fullSummary = embeddingText
await pipeline.update(doc._id, true, update) await pipeline.update(doc._id, this.stageValue, update)
} }
} }
async remove (docs: DocIndexState[], pipeline: FullTextPipeline): Promise<void> { async remove (docs: DocIndexState[], pipeline: FullTextPipeline): Promise<void> {
// will be handled by field processor // will be handled by field processor
for (const doc of docs) { for (const doc of docs) {
await pipeline.update(doc._id, true, {}) await pipeline.update(doc._id, this.stageValue, {})
} }
} }
} }
@ -185,6 +197,10 @@ export async function extractIndexedValues (
if (!isFullTextAttribute(keyAttr)) { if (!isFullTextAttribute(keyAttr)) {
continue continue
} }
if (keyAttr.type._class === core.class.TypeAttachment && extra.length === 0) {
// Skipt attachment id values.
continue
}
const repl = extra.join('#') const repl = extra.join('#')

View File

@ -13,7 +13,17 @@
// limitations under the License. // limitations under the License.
// //
import { Class, Doc, DocIndexState, DocumentQuery, DocumentUpdate, Hierarchy, Ref, Storage } from '@hcengineering/core' import {
Class,
Doc,
DocIndexState,
DocumentQuery,
DocumentUpdate,
Hierarchy,
ModelDb,
Ref,
Storage
} from '@hcengineering/core'
import type { IndexedDoc } from '../types' import type { IndexedDoc } from '../types'
/** /**
@ -21,9 +31,10 @@ import type { IndexedDoc } from '../types'
*/ */
export interface FullTextPipeline { export interface FullTextPipeline {
hierarchy: Hierarchy hierarchy: Hierarchy
model: ModelDb
update: ( update: (
docId: Ref<DocIndexState>, docId: Ref<DocIndexState>,
mark: boolean, mark: boolean | string,
update: DocumentUpdate<DocIndexState>, update: DocumentUpdate<DocIndexState>,
flush?: boolean flush?: boolean
) => Promise<void> ) => Promise<void>
@ -61,6 +72,8 @@ export interface FullTextPipelineStage {
enabled: boolean enabled: boolean
stageValue: boolean | string
initialize: (storage: Storage, pipeline: FullTextPipeline) => Promise<void> initialize: (storage: Storage, pipeline: FullTextPipeline) => Promise<void>
// Collect all changes related to bulk of document states // Collect all changes related to bulk of document states

View File

@ -28,12 +28,17 @@ import core, {
DOMAIN_MODEL, DOMAIN_MODEL,
DOMAIN_TRANSIENT, DOMAIN_TRANSIENT,
DOMAIN_TX, DOMAIN_TX,
generateId,
Hierarchy, Hierarchy,
IndexStageState,
isFullTextAttribute, isFullTextAttribute,
Obj, Obj,
Ref, Ref,
Space Space,
Storage,
TxFactory
} from '@hcengineering/core' } from '@hcengineering/core'
import { deepEqual } from 'fast-equals'
import plugin from '../plugin' import plugin from '../plugin'
/** /**
* @public * @public
@ -155,3 +160,52 @@ export function createStateDoc (
...data ...data
} }
} }
/**
* @public
*/
export async function loadIndexStageStage (
storage: Storage,
state: IndexStageState | undefined,
stageId: string,
field: string,
newValue: any
): Promise<[boolean | string, IndexStageState]> {
if (state === undefined) {
;[state] = await storage.findAll(core.class.IndexStageState, { stageId })
}
const attributes: Record<string, any> = state?.attributes ?? {}
let result: boolean | string | undefined = attributes?.index !== undefined ? `${attributes?.index as number}` : true
if (!deepEqual(attributes[field], newValue)) {
// Not match,
const newIndex = ((attributes.index as number) ?? 0) + 1
result = `${newIndex}`
const ops = new TxFactory(core.account.System)
const data = {
stageId,
attributes: {
[field]: newValue,
index: newIndex
}
}
if (state === undefined) {
const id: Ref<IndexStageState> = generateId()
await storage.tx(ops.createTxCreateDoc(core.class.IndexStageState, plugin.space.DocIndexState, data, id))
state = {
...data,
_class: core.class.IndexStageState,
_id: id,
space: plugin.space.DocIndexState,
modifiedBy: core.account.System,
modifiedOn: Date.now()
}
} else {
await storage.tx(ops.createTxUpdateDoc(core.class.IndexStageState, plugin.space.DocIndexState, state._id, data))
state = { ...state, ...data, modifiedOn: Date.now() }
}
}
return [result, state]
}

View File

@ -708,7 +708,8 @@ export async function createServerStorage (
stages, stages,
hierarchy, hierarchy,
conf.workspace, conf.workspace,
fulltextAdapter.metrics() fulltextAdapter.metrics(),
modelDb
) )
return new FullTextIndex( return new FullTextIndex(
hierarchy, hierarchy,

View File

@ -53,7 +53,9 @@ class ElasticAdapter implements FullTextAdapter {
const mappings = await this.client.indices.getMapping({ const mappings = await this.client.indices.getMapping({
index: toWorkspaceString(this.workspaceId) index: toWorkspaceString(this.workspaceId)
}) })
console.log('Mapping', mappings.body) if (field !== undefined) {
console.log('Mapping', mappings.body)
}
const wsMappings = mappings.body[toWorkspaceString(this.workspaceId)] const wsMappings = mappings.body[toWorkspaceString(this.workspaceId)]
// Collect old values. // Collect old values.
@ -80,7 +82,6 @@ class ElasticAdapter implements FullTextAdapter {
}) })
} }
} }
console.log('Index created ok.')
} catch (err: any) { } catch (err: any) {
console.error(err) console.error(err)
} }
@ -195,7 +196,7 @@ class ElasticAdapter implements FullTextAdapter {
} }
}, },
script: { script: {
source: `Math.abs(cosineSimilarity(params.queryVector, '${options.field}'))`, source: `Math.abs(cosineSimilarity(params.queryVector, '${options.field}')) + 1`,
params: { params: {
queryVector: embedding queryVector: embedding
} }
@ -215,10 +216,7 @@ class ElasticAdapter implements FullTextAdapter {
filter: [ filter: [
{ {
bool: { bool: {
should: [ must: [{ terms: this.getTerms(_classes, '_class') }]
{ terms: this.getTerms(_classes, '_class') },
{ terms: this.getTerms(_classes, 'attachedToClass') }
]
} }
} }
] ]
@ -239,7 +237,7 @@ class ElasticAdapter implements FullTextAdapter {
const min = options?.minScore ?? 75 const min = options?.minScore ?? 75
const hits: any[] = sourceHits.filter((it: any) => it._score > min) const hits: any[] = sourceHits.filter((it: any) => it._score > min)
return hits.map((hit) => ({ ...hit._source, _score: hit._score })) return hits.map((hit) => ({ ...hit._source, _score: hit._score - (options.embeddingBoost ?? 100.0) }))
} catch (err) { } catch (err) {
console.error(JSON.stringify(err, null, 2)) console.error(JSON.stringify(err, null, 2))
return [] return []

View File

@ -19,6 +19,7 @@ import {
DocIndexState, DocIndexState,
DocumentQuery, DocumentQuery,
DocumentUpdate, DocumentUpdate,
IndexStageState,
MeasureContext, MeasureContext,
Ref, Ref,
Storage, Storage,
@ -32,7 +33,8 @@ import {
extractDocKey, extractDocKey,
fieldStateId, fieldStateId,
FullTextPipeline, FullTextPipeline,
IndexedDoc IndexedDoc,
loadIndexStageStage
} from '@hcengineering/server-core' } from '@hcengineering/server-core'
import got from 'got' import got from 'got'
@ -57,6 +59,10 @@ export class LibRetranslateStage implements TranslationStage {
token: string = '' token: string = ''
endpoint: string = '' endpoint: string = ''
stageValue: boolean | string = true
indexState?: IndexStageState
constructor (readonly metrics: MeasureContext, readonly workspaceId: WorkspaceId) {} constructor (readonly metrics: MeasureContext, readonly workspaceId: WorkspaceId) {}
async initialize (storage: Storage, pipeline: FullTextPipeline): Promise<void> { async initialize (storage: Storage, pipeline: FullTextPipeline): Promise<void> {
@ -74,6 +80,11 @@ export class LibRetranslateStage implements TranslationStage {
console.error(err) console.error(err)
this.enabled = false this.enabled = false
} }
;[this.stageValue, this.indexState] = await loadIndexStageStage(storage, this.indexState, this.stageId, 'config', {
enabled: this.enabled,
endpoint: this.endpoint
})
} }
async search ( async search (
@ -229,13 +240,13 @@ export class LibRetranslateStage implements TranslationStage {
return return
} }
await pipeline.update(doc._id, true, update, true) await pipeline.update(doc._id, this.stageValue, update, true)
} }
async remove (docs: DocIndexState[], pipeline: FullTextPipeline): Promise<void> { async remove (docs: DocIndexState[], pipeline: FullTextPipeline): Promise<void> {
// will be handled by field processor // will be handled by field processor
for (const doc of docs) { for (const doc of docs) {
await pipeline.update(doc._id, true, {}) await pipeline.update(doc._id, this.stageValue, {})
} }
} }
} }

View File

@ -5,7 +5,7 @@ import { Configuration } from '@hcengineering/core'
/** /**
* @public * @public
*/ */
export const translateStateId = 'trn-v1a' export const translateStateId = 'trn-v2'
/** /**
* @public * @public

View File

@ -202,30 +202,43 @@ class SessionManager {
const sessions = Array.from(workspace.sessions) const sessions = Array.from(workspace.sessions)
workspace.sessions = [] workspace.sessions = []
for (const s of sessions) { const closeS = async (s: Session, webSocket: WebSocket): Promise<void> => {
// await for message to go to client. // await for message to go to client.
await new Promise((resolve) => { await new Promise((resolve) => {
// Override message handler, to wait for upgrading response from clients. // Override message handler, to wait for upgrading response from clients.
s[1].on('close', () => { webSocket.on('close', () => {
resolve(null) resolve(null)
}) })
s[1].send( webSocket.send(
serialize({ serialize({
result: { result: {
_class: core.class.TxModelUpgrade _class: core.class.TxModelUpgrade
} }
}) })
) )
setTimeout(resolve, 5000) setTimeout(resolve, 1000)
}) })
s[1].close() webSocket.close()
await this.setStatus(ctx, s[0], false) await this.setStatus(ctx, s, false)
} }
try {
await (await workspace.pipeline).close() console.log(workspace.id, 'Clients disconnected. Closing Workspace...')
} catch (err: any) { await Promise.all(sessions.map((s) => closeS(s[0], s[1])))
console.error(err)
const closePipeline = async (): Promise<void> => {
try {
await (await workspace.pipeline).close()
} catch (err: any) {
console.error(err)
}
} }
await Promise.race([
closePipeline,
new Promise((resolve) => {
setTimeout(resolve, 15000)
})
])
console.log(workspace.id, 'Workspace closed...')
} }
async closeWorkspaces (ctx: MeasureContext): Promise<void> { async closeWorkspaces (ctx: MeasureContext): Promise<void> {