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
@Prop(TypeString(), attachment.string.Type)
@Index(IndexKind.FullText)
type!: string
@Prop(TypeTimestamp(), attachment.string.Date)

View File

@ -37,6 +37,7 @@ import {
FullTextData,
FullTextSearchContext,
IndexKind,
IndexStageState,
Interface,
Mixin,
Obj,
@ -251,7 +252,13 @@ export class TDocIndexState extends TDoc implements DocIndexState {
removed!: boolean
// 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)

View File

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

View File

@ -23,6 +23,7 @@ import {
Mixin,
Model,
Prop,
ReadOnly,
TypeBoolean,
TypeDate,
TypeMarkup,
@ -34,14 +35,22 @@ import attachment from '@hcengineering/model-attachment'
import calendar from '@hcengineering/model-calendar'
import chunter from '@hcengineering/model-chunter'
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 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 workbench, { Application, createNavigateAction } from '@hcengineering/model-workbench'
import { IntlString } from '@hcengineering/platform'
import { Applicant, Candidate, Candidates, recruitId, Vacancy, VacancyList } from '@hcengineering/recruit'
import { getEmbeddedLabel, IntlString } from '@hcengineering/platform'
import {
Applicant,
ApplicantMatch,
Candidate,
Candidates,
recruitId,
Vacancy,
VacancyList
} from '@hcengineering/recruit'
import setting from '@hcengineering/setting'
import { KeyBinding } from '@hcengineering/view'
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)
reviews?: number
@Prop(
Collection(recruit.class.ApplicantMatch, getEmbeddedLabel('Vacancy match')),
getEmbeddedLabel('Vacancy Matches')
)
vacancyMatch?: number
}
@Mixin(recruit.mixin.VacancyList, contact.class.Organization)
@ -136,8 +151,33 @@ export class TApplicant extends TTask implements Applicant {
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 {
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, {
view: {
@ -363,7 +403,7 @@ export function createModel (builder: Builder): void {
}
]
},
recruit.viewlet.StatusTableApplicant
recruit.viewlet.TableApplicant
)
builder.createDoc(
view.class.Viewlet,
@ -386,7 +426,19 @@ export function createModel (builder: Builder): void {
],
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> = {
@ -445,6 +497,14 @@ export function createModel (builder: Builder): void {
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, {
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 {
createNavigateAction(builder, key, label, recruit.app.Recruit as Ref<Application>, {
application: recruitId,
@ -822,6 +891,32 @@ export function createModel (builder: Builder): void {
},
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'

View File

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

View File

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

View File

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

View File

@ -26,7 +26,7 @@
}
let search = ''
$: summary = (indexDoc?.attributes as any).summary
$: summary = indexDoc?.fullSummary ?? undefined
$: attributes =
indexDoc !== undefined
@ -51,10 +51,17 @@
</script>
<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-doc text-base max-h-125">
{#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:
{#each summary.split('\n') as line}
{@const hl = search.length > 0 && line.toLowerCase().includes(search.toLowerCase())}
@ -74,6 +81,13 @@
<div class="p-1 flex-row flex-wrap">
{#each attr[1] as doc}
<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}
{@const hl = search.length > 0 && line.toLowerCase().includes(search.toLowerCase())}
<span class:text-md={!hl} class:highlight={hl}>{line}</span>
@ -100,7 +114,7 @@
color: black;
user-select: text;
.highlight {
color: red;
color: blue;
}
}
</style>

View File

@ -13,18 +13,18 @@
// limitations under the License.
-->
<script lang="ts">
import { IconFolder, Label } from '@hcengineering/ui'
import { IconFolder, IconSize, Label } from '@hcengineering/ui'
import type { Space } from '@hcengineering/core'
import presentation from '..'
export let value: Space
export let subtitle: string | undefined = undefined
export let size: 'medium' | 'large'
export let size: IconSize
</script>
<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">
{#if subtitle}<div class="content-dark-color text-sm">{subtitle}</div>{/if}
<div class="content-accent-color overflow-label">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -13,10 +13,23 @@
// limitations under the License.
-->
<script lang="ts">
import { AttachmentStyledBox } from '@hcengineering/attachment-resources'
import chunter from '@hcengineering/chunter'
import type { Contact, Employee, Person } from '@hcengineering/contact'
import contact from '@hcengineering/contact'
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 presentation, {
Card,
@ -32,6 +45,7 @@
Button,
ColorPopup,
createFocusManager,
deviceOptionsStore as deviceInfo,
FocusHandler,
getPlatformColor,
Label,
@ -44,13 +58,16 @@
import CandidateCard from './CandidateCard.svelte'
import VacancyCard from './VacancyCard.svelte'
import VacancyOrgPresenter from './VacancyOrgPresenter.svelte'
import { deviceOptionsStore as deviceInfo } from '@hcengineering/ui'
export let space: Ref<SpaceWithStates>
export let candidate: Ref<Candidate>
export let assignee: Ref<Employee>
export let comment: Markup = ''
$: _comment = comment
export let preserveCandidate = false
export let preserveVacancy = false
let status: Status = OK
let createMore: boolean = false
@ -133,12 +150,22 @@
rank: calcRank(lastOne, undefined),
startDate: 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) {
// Prepare for next
_candidate = '' as Ref<Candidate>
_comment = ''
doc = {
state: selectedState?._id as Ref<State>,
doneState: null,
@ -256,6 +283,8 @@
let verticalContent: boolean = false
$: verticalContent = $deviceInfo.isMobile && $deviceInfo.isPortrait
let btn: HTMLButtonElement
let descriptionBox: AttachmentStyledBox
</script>
<FocusHandler {manager} />
@ -306,6 +335,7 @@
_class={recruit.class.Vacancy}
spaceQuery={{ archived: false }}
spaceOptions={orgOptions}
readonly={preserveVacancy}
label={recruit.string.Vacancy}
create={{
component: recruit.component.CreateVacancy,
@ -324,6 +354,27 @@
</SpaceSelect>
</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">
{#key doc}
<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>
{/if}
{#if vacancy}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="name lines-limit-2"
class:over-underline={!disabled}
@ -95,22 +96,24 @@
}
}}
>
{#if inline}
<div class="flex-row-center">
<VacancyIcon size={'small'} />
<span class="ml-1">
{vacancy.name}
</span>
</div>
{:else}
{vacancy.name}
{/if}
<div class="text-md">
{#if inline}
<div class="flex-row-center">
<VacancyIcon size={'small'} />
<span class="ml-1">
{vacancy.name}
</span>
</div>
{:else}
{vacancy.name}
{/if}
</div>
</div>
{#if company}
<span class="label">{company.name}</span>
{/if}
{#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}
<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 VacancyList from './components/VacancyList.svelte'
import VacancyTemplateEditor from './components/VacancyTemplateEditor.svelte'
import MatchVacancy from './components/MatchVacancy.svelte'
async function createOpinion (object: Doc): Promise<void> {
showPopup(CreateOpinion, { space: object.space, review: object._id })
@ -300,7 +301,9 @@ export default async (): Promise<Resources> => ({
ApplicantFilter,
VacancyList,
VacancyTemplateEditor
VacancyTemplateEditor,
MatchVacancy
},
completion: {
ApplicationQuery: async (

View File

@ -111,7 +111,13 @@ export default mergeIds(recruitId, recruit, {
HasActiveApplicant: '' as IntlString,
HasNoActiveApplicant: '' 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: {
CandidatesPublic: '' as Ref<Space>

View File

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

View File

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

View File

@ -58,6 +58,7 @@
"FilteredViews": "Фильтрованные отображения",
"NewFilteredView": "Новое фильтрованное отображение",
"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 readonly = false
export let prefferedSorting: string = 'modifiedOn'
// If defined, will show a number of dummy items before real data will appear.
export let loadingProps: LoadingProps | undefined = undefined
@ -57,10 +59,16 @@
$: lookup = options?.lookup ?? buildConfigLookup(hierarchy, _class, config)
let sortKey = 'modifiedOn'
let _sortKey = prefferedSorting
$: if (!userSorting) {
_sortKey = prefferedSorting
}
let sortOrder = SortingOrder.Descending
let loading = 0
let userSorting = false
let objects: Doc[] = []
let objectsRecieved = false
const refs: HTMLElement[] = []
@ -71,7 +79,7 @@
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
async function update (
@ -107,7 +115,7 @@
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> => {
selection = row
@ -121,12 +129,13 @@
})
}
function changeSorting (key: string): void {
function changeSorting (key: string | string[]): void {
if (key === '') {
return
}
if (key !== sortKey) {
sortKey = key
userSorting = true
if (key !== _sortKey) {
_sortKey = Array.isArray(key) ? key[0] : key
sortOrder = SortingOrder.Ascending
} else {
sortOrder = sortOrder === SortingOrder.Ascending ? SortingOrder.Descending : SortingOrder.Ascending
@ -211,14 +220,14 @@
{#each model as attribute}
<th
class:sortable={attribute.sortingKey}
class:sorted={attribute.sortingKey === sortKey}
class:sorted={attribute.sortingKey === _sortKey}
on:click={() => changeSorting(attribute.sortingKey)}
>
<div class="antiTable-cells">
{#if attribute.label}
<Label label={attribute.label} />
{/if}
{#if attribute.sortingKey === sortKey}
{#if attribute.sortingKey === _sortKey}
<div class="icon">
{#if sortOrder === SortingOrder.Ascending}
<IconUp size={'small'} />

View File

@ -14,12 +14,14 @@
-->
<script lang="ts">
import type { Class, Doc, DocumentQuery, FindOptions, Ref } from '@hcengineering/core'
import { getEmbeddedLabel } from '@hcengineering/platform'
import { Scroller, tableSP } from '@hcengineering/ui'
import { BuildModelKey } from '@hcengineering/view'
import { onMount } from 'svelte'
import { ActionContext } from '..'
import { focusStore, ListSelectionProvider, SelectDirection, selectionStore } from '../selection'
import { LoadingProps } from '../utils'
import SourcePresenter from './inference/SourcePresenter.svelte'
import Table from './Table.svelte'
export let _class: Ref<Class<Doc>>
@ -44,6 +46,32 @@
onMount(() => {
;(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>
<svelte:window />
@ -57,7 +85,7 @@
<Table
bind:this={table}
{_class}
{config}
config={_config}
{options}
{query}
{showNotification}
@ -66,6 +94,7 @@
highlightRows={true}
enableChecking
checked={$selectionStore ?? []}
{prefferedSorting}
selection={listProvider.current($focusStore)}
on:row-focus={(evt) => {
listProvider.updateFocus(evt.detail)

View File

@ -15,7 +15,8 @@
<script lang="ts">
import { Doc, WithLookup } from '@hcengineering/core'
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 search: string
@ -23,7 +24,14 @@
<!-- svelte-ignore a11y-click-events-have-key-events -->
<span
use:tooltip={{ label: plugin.string.ShowPreviewOnClick }}
on:click={() => {
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 EnumEditor from './components/EnumEditor.svelte'
import FilterBar from './components/filter/FilterBar.svelte'
import FilterTypePopup from './components/filter/FilterTypePopup.svelte'
import ObjectFilter from './components/filter/ObjectFilter.svelte'
import TimestampFilter from './components/filter/TimestampFilter.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 HyperlinkPresenter from './components/HyperlinkPresenter.svelte'
import IntlStringPresenter from './components/IntlStringPresenter.svelte'
import GithubPresenter from './components/linkPresenters/GithubPresenter.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 NumberEditor from './components/NumberEditor.svelte'
import NumberPresenter from './components/NumberPresenter.svelte'
@ -47,31 +56,22 @@ import RolePresenter from './components/RolePresenter.svelte'
import SpacePresenter from './components/SpacePresenter.svelte'
import StringEditor from './components/StringEditor.svelte'
import StringPresenter from './components/StringPresenter.svelte'
import HyperlinkPresenter from './components/HyperlinkPresenter.svelte'
import Table from './components/Table.svelte'
import TableBrowser from './components/TableBrowser.svelte'
import TimestampPresenter from './components/TimestampPresenter.svelte'
import UpDownNavigator from './components/UpDownNavigator.svelte'
import ViewletSettingButton from './components/ViewletSettingButton.svelte'
import ValueSelector from './components/ValueSelector.svelte'
import HTMLEditor from './components/HTMLEditor.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 ViewletSettingButton from './components/ViewletSettingButton.svelte'
import {
afterResult,
beforeResult,
nestedDontMatchResult,
nestedMatchResult,
objectInResult,
objectNinResult,
valueInResult,
valueNinResult,
nestedMatchResult,
nestedDontMatchResult
valueNinResult
} from './filter'
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 ActionHandler } from './components/ActionHandler.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 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 ObjectPresenter } from './components/ObjectPresenter.svelte'
export { default as List } from './components/list/List.svelte'
export { default as TableBrowser } from './components/TableBrowser.svelte'
export { default as ValueSelector } from './components/ValueSelector.svelte'
export { default as MarkupPreviewPopup } from './components/MarkupPreviewPopup.svelte'
export * from './context'
export * from './filter'
export * from './selection'
export * from './viewOptions'
export {
buildModel,
getActiveViewletId,
getCollectionCounter,
getFiltredKeys,
getObjectPresenter,
getObjectPreview,
isCollectionAttr,
LoadingProps,
setActiveViewletId,
getActiveViewletId,
getFiltredKeys,
isCollectionAttr
setActiveViewletId
} from './utils'
export * from './viewOptions'
export {
HTMLPresenter,
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,
Ordering: '' 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
}
}
/**
* @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">
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 {
AnyComponent,
@ -36,7 +36,6 @@
setActiveViewletId,
ViewletSettingButton
} from '@hcengineering/view-resources'
import SourcePresenter from './search/SourcePresenter.svelte'
export let _class: Ref<Class<Doc>>
export let space: Ref<Space> | undefined = undefined
@ -139,20 +138,7 @@
_class,
space,
options: viewlet.options,
config: [
...(search !== ''
? [
{
key: '',
presenter: SourcePresenter,
label: getEmbeddedLabel('#'),
sortingKey: '#score',
props: { search }
}
]
: []),
...(preference?.config ?? viewlet.config)
],
config: preference?.config ?? viewlet.config,
viewlet,
viewOptions,
createItemDialog: createComponent,

View File

@ -98,45 +98,60 @@ import { trackerId } from '@hcengineering/tracker'
import { viewId } from '@hcengineering/view'
import { workbenchId } from '@hcengineering/workbench'
addStringsLoader(loginId, async (lang: string) => await import(`@hcengineering/login-assets/lang/${lang}.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 loginEng from '@hcengineering/login-assets/lang/en.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
*/

View File

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

View File

@ -20,6 +20,7 @@ import core, {
DocumentQuery,
DocumentUpdate,
docUpdKey,
IndexStageState,
MeasureContext,
Ref,
Storage,
@ -36,6 +37,7 @@ import {
FullTextPipelineStage,
IndexedDoc,
isIndexingRequired,
loadIndexStageStage,
RateLimitter
} from '@hcengineering/server-core'
@ -47,7 +49,7 @@ import openaiPlugin, { openAIRatelimitter } from './plugin'
/**
* @public
*/
export const openAIstage = 'emb-v3a'
export const openAIstage = 'emb-v5'
/**
* @public
@ -76,34 +78,37 @@ export class OpenAIEmbeddingsStage implements FullTextPipelineStage {
field = 'openai_embedding'
field_enabled = '_use'
summary_field = 'summary'
enabled = false
clearExcept?: string[] = undefined
updateFields: DocUpdateHandler[] = []
model = process.env.OPENAI_MODEL ?? 'text-embedding-ada-002'
copyToState = true
model = 'text-embedding-ada-002'
tokenLimit = 8191
endpoint = process.env.OPENAI_HOST ?? 'https://api.openai.com/v1/embeddings'
endpoint = 'https://api.openai.com/v1/embeddings'
token = ''
rate = 5
stageValue: boolean | string = true
limitter = new RateLimitter(() => ({ rate: this.rate }))
indexState?: IndexStageState
async update (doc: DocIndexState, update: DocumentUpdate<DocIndexState>): Promise<void> {}
constructor (readonly adapter: FullTextAdapter, readonly metrics: MeasureContext, readonly workspaceId: WorkspaceId) {}
updateSummary (summary: FullSummaryStage): void {
summary.fieldFilter.push((attr, value) => {
if (
attr.type._class === core.class.TypeMarkup &&
(value.toLocaleLowerCase().startsWith('gpt:') || value.toLocaleLowerCase().startsWith('gpt Answer:'))
) {
const tMarkup = attr.type._class === core.class.TypeMarkup
const lowerCase = value.toLocaleLowerCase()
if (tMarkup && (lowerCase.includes('gpt:') || lowerCase.includes('gpt Answer:'))) {
return false
}
return true
@ -150,6 +155,15 @@ export class OpenAIEmbeddingsStage implements FullTextPipelineStage {
console.error(err)
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> {
@ -232,17 +246,18 @@ export class OpenAIEmbeddingsStage implements FullTextPipelineStage {
}
}
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
console.log('search embedding', embedding)
const docs = await this.adapter.searchEmbedding(_classes, query, embedding, {
size,
from,
minScore: 0,
minScore: -100,
embeddingBoost: 100,
field: this.field,
field_enable: this.field_enabled,
fulltextBoost: 1
fulltextBoost: 10
})
return {
docs,
@ -251,6 +266,9 @@ export class OpenAIEmbeddingsStage implements FullTextPipelineStage {
}
async collect (toIndex: DocIndexState[], pipeline: FullTextPipeline): Promise<void> {
if (!this.enabled) {
return
}
for (const doc of toIndex) {
if (pipeline.cancelling) {
return
@ -274,13 +292,7 @@ export class OpenAIEmbeddingsStage implements FullTextPipelineStage {
// No need to index this class, mark embeddings as empty ones.
if (!needIndex) {
await pipeline.update(doc._id, true, {})
return
}
if (this.token === '') {
// No token, just do nothing.
await pipeline.update(doc._id, true, {})
await pipeline.update(doc._id, this.stageValue, {})
return
}
@ -288,10 +300,11 @@ export class OpenAIEmbeddingsStage implements FullTextPipelineStage {
if (this.unauthorized) {
return
}
const embeddingText = (doc.attributes[this.summary_field] as string) ?? ''
const embeddingText = doc.fullSummary ?? ''
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)
@ -322,7 +335,11 @@ export class OpenAIEmbeddingsStage implements FullTextPipelineStage {
[this.field]: embedding,
[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
}
} catch (err: any) {
@ -337,18 +354,15 @@ export class OpenAIEmbeddingsStage implements FullTextPipelineStage {
}
// Print error only first time, and update it in doc index
console.error(err)
return
}
// We need to collect all fields and prepare embedding document.
await pipeline.update(doc._id, true, update)
await pipeline.update(doc._id, this.stageValue, update)
}
async remove (docs: DocIndexState[], pipeline: FullTextPipeline): Promise<void> {
// will be handled by field processor
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,
TxCreateDoc,
TxCUD,
TxProcessor,
TxUpdateDoc
TxProcessor
} from '@hcengineering/core'
import type { TriggerControl } from '@hcengineering/server-core'
import got from 'got'
import { convert } from 'html-to-text'
import { encode } from './encoder/encoder'
import openai, { openAIRatelimitter } from './plugin'
import { chunks, encode } from './encoder/encoder'
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
*/
@ -43,156 +107,200 @@ export async function OnGPTRequest (tx: Tx, tc: TriggerControl): Promise<Tx[]> {
const cud: TxCUD<Doc> = actualTx as TxCUD<Doc>
//
if (tc.hierarchy.isDerived(cud.objectClass, chunter.class.Comment)) {
let msg = ''
//
if (actualTx._class === core.class.TxCreateDoc) {
msg = (cud as TxCreateDoc<Comment>).attributes.message
} 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 await handleComment(tx, tc)
}
if (tc.hierarchy.isDerived(cud.objectClass, recruit.class.ApplicantMatch)) {
return await handleApplicantMatch(tx, tc)
}
}
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
*/
@ -201,3 +309,30 @@ export const openAIPluginImpl = async () => ({
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
stageValue: boolean | string = true
constructor (
readonly storageAdapter: MinioService | undefined,
readonly workspace: WorkspaceId,

View File

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

View File

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

View File

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

View File

@ -20,23 +20,24 @@ import core, {
DocIndexState,
DocumentQuery,
DocumentUpdate,
docUpdKey,
extractDocKey,
FullTextSearchContext,
Hierarchy,
IndexStageState,
isFullTextAttribute,
Ref,
Storage
} from '@hcengineering/core'
import { translate } from '@hcengineering/platform'
import { convert } from 'html-to-text'
import { IndexedDoc } from '../types'
import { contentStageId, DocUpdateHandler, fieldStateId, FullTextPipeline, FullTextPipelineStage } from './types'
import { convert } from 'html-to-text'
import { loadIndexStageStage } from './utils'
/**
* @public
*/
export const summaryStageId = 'sum-v2'
export const summaryStageId = 'sum-v3a'
/**
* @public
@ -54,11 +55,22 @@ export class FullSummaryStage implements FullTextPipelineStage {
// If specified, index only fields with content speciffied.
matchExtra: string[] = [] // 'content', 'base64'] // '#en'
summaryField = 'summary'
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 (
_classes: Ref<Class<Doc>>[],
@ -79,7 +91,7 @@ export class FullSummaryStage implements FullTextPipelineStage {
// No need to index this class, mark embeddings as empty ones.
if (!needIndex) {
await pipeline.update(doc._id, true, {})
await pipeline.update(doc._id, this.stageValue, {})
continue
}
@ -89,16 +101,16 @@ export class FullSummaryStage implements FullTextPipelineStage {
matchExtra: this.matchExtra,
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> {
// will be handled by field processor
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)) {
continue
}
if (keyAttr.type._class === core.class.TypeAttachment && extra.length === 0) {
// Skipt attachment id values.
continue
}
const repl = extra.join('#')

View File

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

View File

@ -28,12 +28,17 @@ import core, {
DOMAIN_MODEL,
DOMAIN_TRANSIENT,
DOMAIN_TX,
generateId,
Hierarchy,
IndexStageState,
isFullTextAttribute,
Obj,
Ref,
Space
Space,
Storage,
TxFactory
} from '@hcengineering/core'
import { deepEqual } from 'fast-equals'
import plugin from '../plugin'
/**
* @public
@ -155,3 +160,52 @@ export function createStateDoc (
...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,
hierarchy,
conf.workspace,
fulltextAdapter.metrics()
fulltextAdapter.metrics(),
modelDb
)
return new FullTextIndex(
hierarchy,

View File

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

View File

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

View File

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